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
+1
View File
@@ -1584,3 +1584,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-04] Parties: the model reads a counterpart only on demand (picker, review list), never when the queue builds: a five-hundred-row queue would cost five hundred calls nobody asked for and a rebuild would repeat them; the reading is a 'model' fact and a search query, never a hard key. The review list ticks rows with exactly one active SCB match but writes nothing until a person approves: an exact legal name plus one active hit is high precision, auto-attaching would still be the system choosing. Exact legal-form names ("Visma Spcs AB", not "Visma") group keys and attach to existing parties: registered company names are unique in Sweden, so this is a key in all but form; the "name never merges" rule keeps applying to fuzzy and form-less names.
[2026-09-04] reset_fiscal_year's next_year_dependency no longer counts an opening-balance verifikat in the following year as reliance (migration 20260904163000 redefines fiscal_year_reset_snapshot; the block now fires only when the following year is locked, closed or has its own closing entry). Why: the old check (opening_balance_entry_id / opening_balances_set on the next period) fired for the dominant migration shape, import the first year with its own #IB and later backfill the year before it, so a backfilled year could never be reset (Aisen & Adison AB, 2026-09-03), while a next year WITHOUT an IB, whose balansrapport really rolls from this year's books, was allowed: the check was inverted relative to actual reliance. An IB in the next year is its own verifikat with its own underlag and survives the reset untouched; the one IB that IS derived from this year's books, the bokslut-generated one, is still refused via this year's closing_entry_id (year_end_state). The preview now returns next_period {name, has_opening_balances} and the dialog says the following year's IB stays as it is, instead of a blocker. Rejected alternative: stornoing the next year's IB inside the reset (it would destroy a correct migration boundary and re-create the #1022 dead end). Sibling fix in the same change: CreatePeriodDialog now derives the name from the dates the user types until the name is hand-edited, which is how a 2022-07-01..2023-12-31 year got saved as "Räkenskapsår 2027" (the seed suggestion is always the next forward year).
[2026-09-04] Underlag attach on a folder-picked Fortnox export (Loftux, 50 of 50 files refused with UNDERLAG_REF_MISMATCH): the multipart filename is reduced to its basename at the route boundary (lib/documents/upload-file-name.ts), rather than teaching the voucher-ref parser to strip directories or adding a client-supplied file_name field. Chrome writes webkitRelativePath as the multipart filename for folder selections, so the attach check saw "2026/06/Leverantörsfakturor/A166_x.pdf" while the preview had resolved File.name "A166_x.pdf"; the two endpoints received the same file under two names and the guard compared them. Stripping inside the parser would turn a typed manual ref "2024/01/31" into voucher 31 (the manual box shares the parser), and a second client-supplied name is no more trustworthy than the first, so the boundary is the only level that fixes the class.
[2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths).
@@ -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,
@@ -18,7 +18,7 @@ vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: vi.fn().mockResolvedValue(null),
}))
import { SYNC_FAILED_MESSAGE } from '../lib/api-client'
import { SYNC_FAILED_MESSAGE, CONNECTOR_UNAVAILABLE_MESSAGE, ConnectorSyncError } from '../lib/api-client'
import { enableBankingExtension } from '../index'
import { syncAccountTransactions } from '../lib/sync'
@@ -180,4 +180,29 @@ describe('POST /sync (enable-banking): retry from error status', () => {
expect(body.error).not.toContain('ASPSP_ERROR')
expect(updateSpy).toHaveBeenCalledWith({ error_message: SYNC_FAILED_MESSAGE })
})
it('answers 503 retryable without renewal advice when the connector hop fails, and leaves the row alone', async () => {
;(syncAccountTransactions as unknown as Mock).mockRejectedValue(
new ConnectorSyncError(200, 'CONNECTOR_BAD_SHAPE', '{}', ['transactions.0.amount: Invalid input'])
)
const updateSpy = vi.fn()
const ctx = makeContext(makeConnection({ status: 'error', error_message: SYNC_FAILED_MESSAGE }), updateSpy)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(503)
const body = await res.json()
expect(body).toMatchObject({ error: CONNECTOR_UNAVAILABLE_MESSAGE, code: 'CONNECTOR_UNAVAILABLE', retryable: true })
expect(body.error).not.toContain('Förnya')
expect(updateSpy).not.toHaveBeenCalled()
expect(ctx.log.warn).toHaveBeenCalledWith(
'[enable-banking] Sync: connector hop failed',
expect.objectContaining({ code: 'CONNECTOR_BAD_SHAPE', issues: ['transactions.0.amount: Invalid input'] })
)
// The raw connector body can carry bank data: never in the log line.
const logged = (ctx.log.warn as Mock).mock.calls.find((c) => c[0] === '[enable-banking] Sync: connector hop failed')?.[1]
expect(logged).not.toHaveProperty('body')
expect(logged).toHaveProperty('bodyLength', 2)
})
})
@@ -12,7 +12,7 @@ vi.mock('../lib/api-client', async () => {
})
import { syncAccountTransactions } from '../lib/sync'
import { SessionExpiredError } from '../lib/api-client'
import { SessionExpiredError, ConnectorSyncError } from '../lib/api-client'
import { buildStableExternalIds } from '@/lib/transactions/external-id'
import type { StoredAccount } from '../types'
@@ -84,11 +84,57 @@ describe('syncAccountTransactions in connector mode', () => {
await expect(syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())).rejects.toBeInstanceOf(SessionExpiredError)
})
it('surfaces other connector failures as errors and refuses an unexpected response shape', async () => {
it('surfaces other connector failures as ConnectorSyncError, never as a dead session', async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'busy', code: 'CONNECTOR_RATE_LIMITED' }), { status: 429 }))
await expect(syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())).rejects.toThrow(/CONNECTOR_RATE_LIMITED/)
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ transactions: 'nope' }), { status: 200 }))
await expect(syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())).rejects.toThrow(/unexpected shape/)
const failed = syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())
await expect(failed).rejects.toThrow(/CONNECTOR_RATE_LIMITED/)
await expect(failed).rejects.toBeInstanceOf(ConnectorSyncError)
await expect(failed).rejects.toMatchObject({ status: 429, code: 'CONNECTOR_RATE_LIMITED' })
await expect(failed).rejects.not.toBeInstanceOf(SessionExpiredError)
})
it('refuses an unexpected response shape and names the failing fields', async () => {
// 2026-09-04: the service answered 200 with a body the contract rejects
// and four canary companies were parked in error with renewal advice.
// The field paths are what lets the service side be fixed.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
transactions: [{ booking_date: '2026-09-03', amount: '12.50', currency: 'SEK', description: 'x' }],
raw_pages: [],
skipped_pending: 0,
returned_min_booking_date: null,
returned_max_booking_date: null,
effective_date_from: null,
pages: 1,
}), { status: 200 }))
const failed = syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())
await expect(failed).rejects.toThrow(/unexpected shape/)
await expect(failed).rejects.toBeInstanceOf(ConnectorSyncError)
const err = await failed.catch((e: unknown) => e) as ConnectorSyncError
expect(err.code).toBe('CONNECTOR_BAD_SHAPE')
expect(err.issues?.some((i) => i.startsWith('transactions.0.amount'))).toBe(true)
expect(err.issues?.some((i) => i.startsWith('transactions.0.counterparty_name'))).toBe(true)
expect(warn).toHaveBeenCalledWith(
'[enable-banking] Connector sync response failed the wire contract',
expect.objectContaining({ connectionId: 'conn-1', accountUid: 'acc-1', issues: err.issues }),
)
warn.mockRestore()
})
it('wraps the timeout abort as ConnectorSyncError CONNECTOR_TIMEOUT', async () => {
const abort = new Error('This operation was aborted')
abort.name = 'AbortError'
fetchMock.mockRejectedValueOnce(abort)
const failed = syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())
await expect(failed).rejects.toBeInstanceOf(ConnectorSyncError)
await expect(failed).rejects.toMatchObject({ status: null, code: 'CONNECTOR_TIMEOUT' })
})
it('wraps a transport failure as ConnectorSyncError', async () => {
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
const failed = syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())
await expect(failed).rejects.toBeInstanceOf(ConnectorSyncError)
await expect(failed).rejects.toMatchObject({ status: null, code: 'CONNECTOR_TRANSPORT' })
})
})
@@ -8,6 +8,8 @@ import {
isSandboxMode,
SessionExpiredError,
AspspUnavailableError,
ConnectorSyncError,
CONNECTOR_UNAVAILABLE_MESSAGE,
REAUTH_REQUIRED_MESSAGE,
SYNC_FAILED_MESSAGE,
BANK_UNAVAILABLE_MESSAGE,
@@ -957,6 +959,32 @@ export const enableBankingExtension: Extension = {
)
}
// The connector hop failed (timeout, error envelope, contract
// mismatch): same treatment, the PSD2 session is not the problem
// and the row keeps whatever status it has.
if (error instanceof ConnectorSyncError) {
// Never the body: a connector response can carry transaction and
// personal data, and this log line sits next to user/connection ids.
log.warn('[enable-banking] Sync: connector hop failed', {
code: error.code,
status: error.status,
issues: error.issues,
bodyLength: error.body.length,
user_id: user.id,
connection_id,
bankName: connection.bank_name,
})
return NextResponse.json(
{
error: CONNECTOR_UNAVAILABLE_MESSAGE,
code: 'CONNECTOR_UNAVAILABLE',
retryable: true,
connection_id: connection.id,
},
{ status: 503 }
)
}
log.error('[enable-banking] Sync handler error', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
@@ -16,7 +16,7 @@ vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: (...args: unknown[]) => mocks.emit(...args) },
}))
import { SessionExpiredError, REAUTH_REQUIRED_MESSAGE } from '../api-client'
import { SessionExpiredError, REAUTH_REQUIRED_MESSAGE, ConnectorSyncError } from '../api-client'
import { SYNC_COOLDOWN_MS, triggerConnectionSync } from '../trigger-sync'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
@@ -250,6 +250,18 @@ describe('triggerConnectionSync', () => {
expect(await run()).toMatchObject({ ok: false, code: 'BANK_SYNC_NO_ACCOUNTS' })
})
it('answers retryable and leaves the row alone when the connector hop fails', async () => {
state.connection = connection({ status: 'error', error_message: 'old' })
mocks.syncAccountTransactions.mockRejectedValue(new ConnectorSyncError(null, 'CONNECTOR_TIMEOUT', 'aborted'))
const result = await run()
expect(result).toMatchObject({ ok: false, code: 'BANK_SYNC_FAILED', status: 'error' })
expect(state.updates.some((u) => 'status' in u || 'error_message' in u)).toBe(false)
expect(log.warn).toHaveBeenCalledWith(
'agent-triggered bank sync: connector hop failed',
expect.objectContaining({ code: 'CONNECTOR_TIMEOUT' }),
)
})
it('flips the connection to expired when the bank reports the session dead', async () => {
mocks.syncAccountTransactions.mockRejectedValue(new SessionExpiredError(401, 'consent closed'))
const result = await run()
@@ -350,6 +350,43 @@ export class AspspUnavailableError extends Error {
}
}
/**
* Thrown when the connector hop itself failed: the Accounted Connect service
* answered with an error envelope other than a dead session, timed out, could
* not be reached, or answered 200 with a body that fails the wire contract.
* None of these say anything about the PSD2 session, so callers treat it like
* AspspUnavailableError: retryable, no status flip, no renewal advice. On
* 2026-09-04 a contract mismatch parked four canary companies in 'error' with
* "förnya anslutningen" and cost them BankID round trips that fixed nothing.
* `issues` carries the failing field paths so the service side can be fixed
* from the server log instead of guessed at. `body` (head of the response) is
* kept for a debugger with the object in hand and is deliberately absent from
* the message and from every log line: a connector response can carry
* transaction and personal data.
*/
export class ConnectorSyncError extends Error {
constructor(
readonly status: number | null,
readonly code: string,
readonly body: string,
readonly issues?: string[]
) {
super(
code === 'CONNECTOR_BAD_SHAPE'
? `Connector bank sync answered with an unexpected shape: ${(issues ?? []).join('; ') || 'no issues reported'}`
: `Connector bank sync failed (${status ?? 'no response'} ${code})`
)
this.name = 'ConnectorSyncError'
}
}
/**
* User-facing message for a ConnectorSyncError. Says explicitly that the
* connection does NOT need renewing: the bank session is not the problem.
*/
export const CONNECTOR_UNAVAILABLE_MESSAGE =
'Synkningen via Accounted Connect misslyckades tillfälligt. Försök igen om en stund. Anslutningen behöver inte förnyas.'
// API Helper
async function authenticatedFetch(
+51 -23
View File
@@ -1,5 +1,11 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getAllTransactionsWithRaw, convertTransaction, getAccountBalance, SessionExpiredError } from './api-client'
import {
getAllTransactionsWithRaw,
convertTransaction,
getAccountBalance,
SessionExpiredError,
ConnectorSyncError,
} from './api-client'
import { historyWindowDays } from './history-window'
import { bankSyncResponseSchema, connectorErrorSchema } from '@accounted/connect-contract'
import { bankConnectorMode, CONNECTOR_COMPANY_HEADER } from '@/lib/connect/instance/upstreams'
@@ -113,26 +119,37 @@ async function fetchBookedViaConnector(
let response: Response
let text: string
try {
response = await fetch(`${connector.baseUrl}/sync`, {
method: 'POST',
signal: controller.signal,
redirect: 'error',
headers: {
Authorization: `Bearer ${connector.key}`,
'Content-Type': 'application/json',
Accept: 'application/json',
[CONNECTOR_COMPANY_HEADER]: args.companyId,
},
body: JSON.stringify({
session_id: sessionId,
account_uid: args.account.uid,
account_currency: args.account.currency,
date_from: args.fromDate,
date_to: args.toDate,
...(args.strategy ? { strategy: args.strategy } : {}),
}),
})
text = await response.text()
try {
response = await fetch(`${connector.baseUrl}/sync`, {
method: 'POST',
signal: controller.signal,
redirect: 'error',
headers: {
Authorization: `Bearer ${connector.key}`,
'Content-Type': 'application/json',
Accept: 'application/json',
[CONNECTOR_COMPANY_HEADER]: args.companyId,
},
body: JSON.stringify({
session_id: sessionId,
account_uid: args.account.uid,
account_currency: args.account.currency,
date_from: args.fromDate,
date_to: args.toDate,
...(args.strategy ? { strategy: args.strategy } : {}),
}),
})
text = await response.text()
} catch (err) {
// Transport failure or the abort above: the connector hop failed, the
// PSD2 session is untouched. Never a status flip (ConnectorSyncError).
const aborted = err instanceof Error && err.name === 'AbortError'
throw new ConnectorSyncError(
null,
aborted ? 'CONNECTOR_TIMEOUT' : 'CONNECTOR_TRANSPORT',
err instanceof Error ? err.message : String(err),
)
}
} finally {
clearTimeout(timeout)
}
@@ -148,10 +165,21 @@ async function fetchBookedViaConnector(
if (response.status === 410 || code === 'CONNECTOR_BANK_SESSION_EXPIRED') {
throw new SessionExpiredError(response.status, text)
}
throw new Error(`Connector bank sync failed (${response.status} ${code})`)
throw new ConnectorSyncError(response.status, code, text.slice(0, 500))
}
const parsed = bankSyncResponseSchema.safeParse(json)
if (!parsed.success) throw new Error('Connector bank sync answered with an unexpected shape')
if (!parsed.success) {
// The field paths are the only thing that lets the service side be fixed:
// a bare "unexpected shape" left the 2026-09-04 canary failure undiagnosable.
const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
console.warn('[enable-banking] Connector sync response failed the wire contract', {
connectionId: args.connectionId,
accountUid: args.account.uid,
status: response.status,
issues,
})
throw new ConnectorSyncError(response.status, 'CONNECTOR_BAD_SHAPE', text.slice(0, 500), issues)
}
return parsed.data
}
@@ -27,6 +27,7 @@ import { syncAccountTransactions, type SyncOptions } from './sync'
import {
SessionExpiredError,
AspspUnavailableError,
ConnectorSyncError,
REAUTH_REQUIRED_MESSAGE,
SYNC_FAILED_MESSAGE,
} from './api-client'
@@ -295,6 +296,23 @@ export async function triggerConnectionSync(
}
}
if (error instanceof ConnectorSyncError) {
// The connector hop failed: same treatment as the bank being
// unavailable. The session is fine and the row is left alone.
log.warn('agent-triggered bank sync: connector hop failed', {
connectionId,
code: error.code,
status: error.status,
issues: error.issues,
})
return {
ok: false,
code: 'BANK_SYNC_FAILED',
connection_id: connectionId,
status: connection.status as string,
}
}
log.error('agent-triggered bank sync failed', {
connectionId,
message: error instanceof Error ? error.message : String(error),