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:
Mattsson
2026-09-05 00:28:27 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent abaab1bf8d
commit 1e1e1d5d17
10 changed files with 304 additions and 34 deletions
@@ -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,