* fix(enable-banking): stop reading every ASPSP_ERROR as a too-wide window (#2202) ASPSP_ERROR is Enable Banking's generic wrapper for any upstream bank failure, so "history window beyond the PSD2 limit" and "the bank is refusing right now" arrived as the same string, and every rejection walked the whole 90/60/30 narrowing ladder: one user click cost up to five upstream calls against a bank that was already saying no, the failure surfaced with "förnya anslutningen" advice that fixes nothing, and a sync that did narrow was reported as complete. What the account has accepted before is the signal that tells the two apart. sync.ts now records the widest window (days before date_to) each account's bank has answered, on accounts_data as accepted_history_days (no migration; persisted by the same write-back as dedup_scope). On a rejected window: no wider than that = the bank is unavailable, stop after one call; wider = one retry straight at the accepted width, then stop. Without a record (first sync, legacy rows) the ladder runs as before, but its exhaustion is now AspspUnavailableError too. The web sync route maps that to 503 BANK_UNAVAILABLE with copy that says the connection does not need renewing and leaves the row alone; the agent path keeps the contract code BANK_SYNC_FAILED but no longer persists renewal advice. getAllTransactionsWithRaw returns the requested and the effective date_from plus a narrowed flag; the sync result and the /sync response carry them (history_from), and the settings toast says from which date the history is complete when the bank cut the window. Not done: a per-account backoff for the user-triggered route (the agent path already has the 15-minute lease from #2165), and using the envelope's `detail` field (one sample, identical to a width rejection). Closes #2202 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy * docs(decisions): carry the batch's decision lines (#2237, #2203, #2214) here --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
0c82db88da
commit
5eac2a492c
@@ -16,6 +16,7 @@ import { getAuthorizationHeader } from './jwt'
|
||||
import { deriveTransactionLabel } from './transaction-label'
|
||||
import { FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
|
||||
import { bankConnectorMode, CONNECTOR_COMPANY_HEADER } from '@/lib/connect/instance/upstreams'
|
||||
import { dateFromDaysBefore, historyWindowDays } from './history-window'
|
||||
|
||||
// Prefer _PRODUCTION variant; sandbox uses api.tilisy.com, production uses api.enablebanking.com
|
||||
const ENABLE_BANKING_API_URL =
|
||||
@@ -232,6 +233,14 @@ export const REAUTH_REQUIRED_MESSAGE =
|
||||
'Bankanslutningen har löpt ut. Förnya anslutningen för att fortsätta synka.'
|
||||
export const SYNC_FAILED_MESSAGE =
|
||||
'Banksynkningen misslyckades. Försök igen, eller förnya anslutningen om felet kvarstår.'
|
||||
/**
|
||||
* The bank is refusing right now and narrowing the window cannot help. Says
|
||||
* explicitly that the connection does NOT need renewing: a transient
|
||||
* ASPSP_ERROR used to surface as SYNC_FAILED_MESSAGE, whose "förnya
|
||||
* anslutningen" advice costs a BankID round trip and fixes nothing (#2202).
|
||||
*/
|
||||
export const BANK_UNAVAILABLE_MESSAGE =
|
||||
'Banken svarade inte just nu (tillfälligt fel hos banken). Försök igen om en stund. Anslutningen behöver inte förnyas.'
|
||||
|
||||
/**
|
||||
* Thrown when a transactions fetch fails because the PSD2 session is dead
|
||||
@@ -250,6 +259,32 @@ export class SessionExpiredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the ASPSP rejected the transactions request and narrowing the
|
||||
* history window is not the answer (issue #2202):
|
||||
*
|
||||
* - 'window-already-accepted': the rejected window is no wider than one this
|
||||
* account has fetched successfully before (StoredAccount.accepted_history_days),
|
||||
* so width is not the problem; the bank is refusing for its own reasons.
|
||||
* - 'ladder-exhausted': every narrower window was refused too.
|
||||
*
|
||||
* Either way the sync should say "try again later" and must not flip the
|
||||
* connection to expired/error or prompt a consent renewal. The message keeps
|
||||
* the `Failed to get transactions (status)` prefix so existing log matching
|
||||
* still works; carries the raw body for the server log only.
|
||||
*/
|
||||
export class AspspUnavailableError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly body: string,
|
||||
readonly reason: 'window-already-accepted' | 'ladder-exhausted',
|
||||
readonly dateFrom: string | undefined
|
||||
) {
|
||||
super(`Failed to get transactions (${status}), bank unavailable [${reason}]: ${body}`)
|
||||
this.name = 'AspspUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
// API Helper
|
||||
|
||||
async function authenticatedFetch(
|
||||
@@ -822,7 +857,8 @@ export async function getAllTransactions(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
strategy?: TransactionsFetchStrategy
|
||||
strategy?: TransactionsFetchStrategy,
|
||||
options?: HistoryWindowOptions
|
||||
): Promise<Transaction[]> {
|
||||
const allTransactions: Transaction[] = []
|
||||
let continuationKey: string | undefined
|
||||
@@ -854,6 +890,7 @@ export async function getAllTransactions(
|
||||
activeStrategy,
|
||||
activeDateFrom,
|
||||
dateTo,
|
||||
acceptedHistoryDays: options?.acceptedHistoryDays,
|
||||
})
|
||||
if (recovery.type === 'drop-strategy') {
|
||||
console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
|
||||
@@ -875,6 +912,9 @@ export async function getAllTransactions(
|
||||
activeDateFrom = recovery.dateFrom
|
||||
continue
|
||||
}
|
||||
if (recovery.type === 'aspsp-unavailable') {
|
||||
throw bankUnavailable(accountUid, err.status, err.body, recovery.reason, activeDateFrom, dateTo)
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
@@ -901,11 +941,31 @@ export async function getAllTransactions(
|
||||
*/
|
||||
const ASPSP_HISTORY_FALLBACK_DAYS = [90, 60, 30] as const
|
||||
|
||||
/**
|
||||
* Per-account knowledge the caller can hand the pagination loops (#2202).
|
||||
*/
|
||||
export interface HistoryWindowOptions {
|
||||
/**
|
||||
* Widest window (whole days before date_to) this account's bank has
|
||||
* accepted before: StoredAccount.accepted_history_days. When set, a
|
||||
* rejected window that is no wider than this is reported as the bank being
|
||||
* unavailable (AspspUnavailableError) instead of narrowed, and a wider
|
||||
* rejected window jumps straight to this width instead of walking the
|
||||
* 90/60/30 ladder. Unset (first sync, legacy rows): the ladder runs as
|
||||
* before.
|
||||
*/
|
||||
acceptedHistoryDays?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable Banking wraps upstream-bank failures in a generic envelope, e.g.
|
||||
* {"code":400,"message":"Error interacting with ASPSP","error":"ASPSP_ERROR"}.
|
||||
* A too-wide history window is the most common trigger: see the date-narrowing
|
||||
* fallback in getAllTransactionsWithRaw.
|
||||
* The envelope is the SAME for a history window beyond the bank's PSD2 limit
|
||||
* and for a bank that is refusing right now (maintenance, throttling, an
|
||||
* upstream error): the one sample of a transient failure on record carried
|
||||
* "detail":"Unknown error", exactly like a window rejection does. So the
|
||||
* string alone cannot tell the two apart; planFirstPageRecovery uses what the
|
||||
* account has accepted before to decide (issue #2202).
|
||||
*/
|
||||
function isAspspError(body: string): boolean {
|
||||
return body.includes('ASPSP_ERROR') || body.includes('interacting with ASPSP')
|
||||
@@ -944,14 +1004,22 @@ function nextNarrowerDateFrom(
|
||||
* continuation_key is scoped to the window/strategy that produced it, so the
|
||||
* query is never rewritten mid-pagination.
|
||||
*
|
||||
* - 'drop-strategy' : an unsupported strategy enum: retry the same window.
|
||||
* - 'narrow' : the ASPSP rejected the history window: retry with a
|
||||
* narrower date_from (the bank caps history below the ask).
|
||||
* - 'give-up' : nothing left to try; the caller should rethrow.
|
||||
* - 'drop-strategy' : an unsupported strategy enum: retry the same window.
|
||||
* - 'narrow' : the ASPSP rejected the history window: retry with a
|
||||
* narrower date_from (the bank caps history below the
|
||||
* ask). With a known accepted width the retry jumps
|
||||
* straight to it; without one it steps down the
|
||||
* 90/60/30 ladder.
|
||||
* - 'aspsp-unavailable' : the ASPSP rejected a window it has accepted before,
|
||||
* or every narrower window too: width is not the
|
||||
* problem, the bank is refusing right now. The caller
|
||||
* throws AspspUnavailableError (#2202).
|
||||
* - 'give-up' : nothing left to try; the caller should rethrow.
|
||||
*/
|
||||
type FirstPageRecovery =
|
||||
| { type: 'drop-strategy' }
|
||||
| { type: 'narrow'; dateFrom: string }
|
||||
| { type: 'aspsp-unavailable'; reason: AspspUnavailableError['reason'] }
|
||||
| { type: 'give-up' }
|
||||
|
||||
function planFirstPageRecovery(args: {
|
||||
@@ -962,18 +1030,61 @@ function planFirstPageRecovery(args: {
|
||||
activeStrategy: TransactionsFetchStrategy | undefined
|
||||
activeDateFrom: string | undefined
|
||||
dateTo: string | undefined
|
||||
acceptedHistoryDays: number | undefined
|
||||
}): FirstPageRecovery {
|
||||
const { status, body, page, hasContinuationKey, activeStrategy, activeDateFrom, dateTo } = args
|
||||
const {
|
||||
status,
|
||||
body,
|
||||
page,
|
||||
hasContinuationKey,
|
||||
activeStrategy,
|
||||
activeDateFrom,
|
||||
dateTo,
|
||||
acceptedHistoryDays,
|
||||
} = args
|
||||
if (status !== 400 || page !== 0 || hasContinuationKey) return { type: 'give-up' }
|
||||
// Drop an unsupported strategy first: preserves the full requested window.
|
||||
if (activeStrategy) return { type: 'drop-strategy' }
|
||||
// Then handle the ASPSP rejecting the window itself (e.g. Danske past ~90
|
||||
// days): step date_from toward date_to so a partial sync survives.
|
||||
if (isAspspError(body)) {
|
||||
const dateFrom = nextNarrowerDateFrom(activeDateFrom, dateTo)
|
||||
if (!isAspspError(body)) return { type: 'give-up' }
|
||||
|
||||
// A window this account has fetched before is the strongest signal on hand
|
||||
// that width is not the problem: stop after this one call. Wider than that:
|
||||
// retry once at the known-good width, which is the cheapest test with the
|
||||
// best odds, instead of spending three rungs on a bank that is saying no.
|
||||
const requestedDays = historyWindowDays(activeDateFrom, dateTo)
|
||||
if (acceptedHistoryDays !== undefined && requestedDays !== undefined) {
|
||||
if (requestedDays <= acceptedHistoryDays) {
|
||||
return { type: 'aspsp-unavailable', reason: 'window-already-accepted' }
|
||||
}
|
||||
const dateFrom = dateFromDaysBefore(dateTo, acceptedHistoryDays)
|
||||
if (dateFrom) return { type: 'narrow', dateFrom }
|
||||
}
|
||||
return { type: 'give-up' }
|
||||
|
||||
// No accepted width on record (first sync, legacy rows): step date_from
|
||||
// toward date_to (e.g. Danske past ~90 days) so a partial sync survives.
|
||||
const dateFrom = nextNarrowerDateFrom(activeDateFrom, dateTo)
|
||||
if (dateFrom) return { type: 'narrow', dateFrom }
|
||||
return { type: 'aspsp-unavailable', reason: 'ladder-exhausted' }
|
||||
}
|
||||
|
||||
/** Log and build the error both pagination loops throw on 'aspsp-unavailable'. */
|
||||
function bankUnavailable(
|
||||
accountUid: string,
|
||||
status: number,
|
||||
body: string,
|
||||
reason: AspspUnavailableError['reason'],
|
||||
activeDateFrom: string | undefined,
|
||||
dateTo: string | undefined
|
||||
): AspspUnavailableError {
|
||||
console.warn('[enable-banking] ASPSP refused a request that narrowing cannot fix; treating the bank as unavailable', {
|
||||
accountUid,
|
||||
reason,
|
||||
dateFrom: activeDateFrom,
|
||||
dateTo,
|
||||
status,
|
||||
body,
|
||||
})
|
||||
return new AspspUnavailableError(status, body, reason, activeDateFrom)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -986,16 +1097,34 @@ function planFirstPageRecovery(args: {
|
||||
*
|
||||
* If the ASPSP then still rejects the first page with an ASPSP_ERROR (typically
|
||||
* a history window beyond the bank's PSD2 limit, e.g. Danske past ~90 days),
|
||||
* progressively narrow date_from toward date_to (90→60→30 days) so a partial
|
||||
* sync of the recent window survives instead of failing outright. Logs a
|
||||
* warning on each narrowing.
|
||||
* progressively narrow date_from toward date_to (90→60→30 days, or straight to
|
||||
* options.acceptedHistoryDays when the account has one) so a partial sync of
|
||||
* the recent window survives instead of failing outright. Logs a warning on
|
||||
* each narrowing. A rejection that narrowing cannot fix throws
|
||||
* AspspUnavailableError (see planFirstPageRecovery).
|
||||
*
|
||||
* The result names the date_from the bank finally ANSWERED (effectiveDateFrom)
|
||||
* next to the one that was asked for, so a truncated window is visible to the
|
||||
* caller instead of looking like a complete sync (#2202).
|
||||
*/
|
||||
export interface TransactionsWithRaw {
|
||||
transactions: Transaction[]
|
||||
rawPages: string[]
|
||||
/** The date_from the caller asked for. */
|
||||
requestedDateFrom: string | undefined
|
||||
/** The date_from the bank answered: equal to requestedDateFrom unless narrowed. */
|
||||
effectiveDateFrom: string | undefined
|
||||
/** True when the bank refused the requested window and a narrower one was used. */
|
||||
narrowed: boolean
|
||||
}
|
||||
|
||||
export async function getAllTransactionsWithRaw(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
strategy?: TransactionsFetchStrategy
|
||||
): Promise<{ transactions: Transaction[]; rawPages: string[] }> {
|
||||
strategy?: TransactionsFetchStrategy,
|
||||
options?: HistoryWindowOptions
|
||||
): Promise<TransactionsWithRaw> {
|
||||
const allTransactions: Transaction[] = []
|
||||
const rawPages: string[] = []
|
||||
let continuationKey: string | undefined
|
||||
@@ -1027,6 +1156,7 @@ export async function getAllTransactionsWithRaw(
|
||||
activeStrategy,
|
||||
activeDateFrom,
|
||||
dateTo,
|
||||
acceptedHistoryDays: options?.acceptedHistoryDays,
|
||||
})
|
||||
if (recovery.type === 'drop-strategy') {
|
||||
console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
|
||||
@@ -1048,6 +1178,9 @@ export async function getAllTransactionsWithRaw(
|
||||
activeDateFrom = recovery.dateFrom
|
||||
continue
|
||||
}
|
||||
if (recovery.type === 'aspsp-unavailable') {
|
||||
throw bankUnavailable(accountUid, response.status, body, recovery.reason, activeDateFrom, dateTo)
|
||||
}
|
||||
// An expired PSD2 session is an expected end of life for a consent, not
|
||||
// a failure: SessionExpiredError below flips the connection to 'expired'
|
||||
// and asks the user to re-authorize. Log it at warn so only the genuine
|
||||
@@ -1087,7 +1220,13 @@ export async function getAllTransactionsWithRaw(
|
||||
if (!continuationKey) break
|
||||
}
|
||||
|
||||
return { transactions: allTransactions, rawPages }
|
||||
return {
|
||||
transactions: allTransactions,
|
||||
rawPages,
|
||||
requestedDateFrom: dateFrom,
|
||||
effectiveDateFrom: activeDateFrom,
|
||||
narrowed: activeDateFrom !== dateFrom,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user