fix(enable-banking): connector-hop failures no longer park a connection in error (#2296)
* fix(enable-banking): connector-hop failures no longer park a connection in error On 2026-09-04 the daily cron flipped four canary companies (bank sync routed through Accounted Connect, PR #2205) to 'error' with "Banksynkningen misslyckades ... forny anslutningen" because the service answered 200 with a body that fails bankSyncResponseSchema. The PSD2 sessions were fine; users re-authorized Danske, SEB and Revolut for nothing, and the bare "unexpected shape" message left the contract mismatch undiagnosable. - New ConnectorSyncError (status, code, body, Zod issue paths) thrown for every connector-hop failure: transport, timeout, error envelope other than a dead session, and wire-contract mismatch. The failing field paths are logged at the throw site. - Cron: AspspUnavailableError and ConnectorSyncError are transient. The row is left untouched (no status flip, no renewal advice) and logged at warn level; the health probe on the same run still catches a dead session. - Manual sync (POST /sync) and agent sync (triggerConnectionSync) answer retryable with CONNECTOR_UNAVAILABLE_MESSAGE, which says explicitly that the connection does not need renewing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L * fix(enable-banking): keep the connector response body out of the sync log line Superagent P2 and CodeRabbit: POST /sync logged up to 500 chars of the raw connector body next to user and connection ids. A connector response can carry transaction and personal data, so the log line now carries only code, status, the Zod issue paths and the body length. The BAD_SHAPE message no longer falls back to the body either. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
abaab1bf8d
commit
1e1e1d5d17
@@ -86,6 +86,8 @@ vi.mock('@/extensions/general/enable-banking/lib/api-client', async () => {
|
||||
import {
|
||||
REAUTH_REQUIRED_MESSAGE,
|
||||
SessionExpiredError,
|
||||
AspspUnavailableError,
|
||||
ConnectorSyncError,
|
||||
} from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import { GET } from '../route'
|
||||
|
||||
@@ -458,6 +460,60 @@ describe('GET /api/extensions/enable-banking/sync/cron: failure log level', () =
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/extensions/enable-banking/sync/cron: transient failures leave the row alone', () => {
|
||||
// 2026-09-04: a connector contract mismatch parked four canary companies in
|
||||
// 'error' with "förnya anslutningen", and users re-authorized consents that
|
||||
// were fine. Neither a connector-hop failure nor a bank refusing right now
|
||||
// says anything about the PSD2 session, so the row keeps its status and the
|
||||
// health probe still checks the session.
|
||||
it('does not park a connection in error when the connector hop fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
state.active = [connection()]
|
||||
mocks.syncAccountTransactions.mockRejectedValue(
|
||||
new ConnectorSyncError(200, 'CONNECTOR_BAD_SHAPE', '{"transactions":[]}', ['transactions.0.amount: Invalid input']),
|
||||
)
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
|
||||
expect(state.updates).toEqual([])
|
||||
await expect(response.json()).resolves.toMatchObject({ processed: 1 })
|
||||
const errorLines = consoleError.mock.calls.map(call => String(call[0]))
|
||||
expect(errorLines.some(line => line.includes('sync failed for connection'))).toBe(false)
|
||||
consoleError.mockRestore()
|
||||
consoleWarn.mockRestore()
|
||||
})
|
||||
|
||||
it('does not park a connection in error when the bank is refusing right now', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
state.active = [connection()]
|
||||
mocks.syncAccountTransactions.mockRejectedValue(
|
||||
new AspspUnavailableError(400, '{"error":"ASPSP_ERROR"}', 'window-already-accepted', '2026-08-28'),
|
||||
)
|
||||
|
||||
await GET(cronRequest())
|
||||
|
||||
expect(state.updates).toEqual([])
|
||||
const errorLines = consoleError.mock.calls.map(call => String(call[0]))
|
||||
expect(errorLines.some(line => line.includes('sync failed for connection'))).toBe(false)
|
||||
consoleError.mockRestore()
|
||||
consoleWarn.mockRestore()
|
||||
})
|
||||
|
||||
it('still probes the session of a connection whose sync failed transiently', async () => {
|
||||
state.active = [connection()]
|
||||
state.probeCandidates = [connection()]
|
||||
mocks.syncAccountTransactions.mockRejectedValue(new ConnectorSyncError(null, 'CONNECTOR_TIMEOUT', 'aborted'))
|
||||
mocks.probeSessionHealth.mockResolvedValue('dead')
|
||||
|
||||
await GET(cronRequest())
|
||||
|
||||
expect(mocks.probeSessionHealth).toHaveBeenCalledTimes(1)
|
||||
expect(state.updates.at(-1)?.payload).toMatchObject({ status: 'expired' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/extensions/enable-banking/sync/cron: incremental lookback', () => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
// Pin the clock: the route reads Date.now() after the fixture does, and a
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getDaysUntilExpiry,
|
||||
probeSessionHealth,
|
||||
SessionExpiredError,
|
||||
AspspUnavailableError,
|
||||
ConnectorSyncError,
|
||||
REAUTH_REQUIRED_MESSAGE,
|
||||
SYNC_FAILED_MESSAGE,
|
||||
} from '@/extensions/general/enable-banking/lib/api-client'
|
||||
@@ -385,6 +387,13 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
// the short Swedish user message in both cases: the raw Enable Banking
|
||||
// error body (an English JSON envelope) stays in the server log below.
|
||||
const isSessionDead = error instanceof SessionExpiredError
|
||||
// A bank refusing right now, or the connector hop failing (timeout,
|
||||
// error envelope, contract mismatch), says nothing about the PSD2
|
||||
// session: retryable, and the row is left alone. Parking it in 'error'
|
||||
// with SYNC_FAILED_MESSAGE told users to renew a consent that was fine
|
||||
// (four canary companies on 2026-09-04). The probe below still checks
|
||||
// the session, so a dead one is caught anyway.
|
||||
const isTransient = error instanceof AspspUnavailableError || error instanceof ConnectorSyncError
|
||||
const failureStatus = isSessionDead ? 'expired' : 'error'
|
||||
const failureMessage = isSessionDead ? REAUTH_REQUIRED_MESSAGE : SYNC_FAILED_MESSAGE
|
||||
|
||||
@@ -403,14 +412,24 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
...failureContext,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
} else if (isTransient) {
|
||||
ctx.log.warn('transient bank sync failure, connection left untouched', {
|
||||
...failureContext,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
...(error instanceof ConnectorSyncError
|
||||
? { connectorCode: error.code, connectorStatus: error.status, issues: error.issues }
|
||||
: { aspspReason: error instanceof AspspUnavailableError ? error.reason : undefined }),
|
||||
})
|
||||
} else {
|
||||
ctx.log.error('sync failed for connection', error as Error, failureContext)
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: failureStatus, error_message: failureMessage })
|
||||
.eq('id', connection.id)
|
||||
if (!isTransient) {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: failureStatus, error_message: failureMessage })
|
||||
.eq('id', connection.id)
|
||||
}
|
||||
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
|
||||
Reference in New Issue
Block a user