fix(simplefin): retry transient fetch failures (3 attempts, 1s/2s backoff)

This commit is contained in:
null 2026-06-06 15:06:12 -05:00
parent a66fe13bc6
commit 7d42d119c0
1 changed files with 40 additions and 19 deletions

View File

@ -83,6 +83,9 @@ async function claimSetupToken(setupToken) {
return accessUrl; return accessUrl;
} }
const FETCH_RETRY_ATTEMPTS = 3;
const FETCH_RETRY_DELAYS = [1000, 2000]; // ms between attempts 1→2 and 2→3
async function fetchAccountsAndTransactions(accessUrl, sinceEpoch) { async function fetchAccountsAndTransactions(accessUrl, sinceEpoch) {
let url; let url;
try { try {
@ -95,18 +98,34 @@ async function fetchAccountsAndTransactions(accessUrl, sinceEpoch) {
const baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/?$/, '')}`; const baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/?$/, '')}`;
const endpoint = `${baseUrl}/accounts?start-date=${Math.floor(sinceEpoch)}&version=2`; const endpoint = `${baseUrl}/accounts?start-date=${Math.floor(sinceEpoch)}&version=2`;
let data; let lastErr;
for (let attempt = 0; attempt < FETCH_RETRY_ATTEMPTS; attempt++) {
if (attempt > 0) await new Promise(r => setTimeout(r, FETCH_RETRY_DELAYS[attempt - 1]));
let res;
try { try {
const res = await fetch(endpoint, { res = await fetch(endpoint, {
headers: { 'Authorization': `Basic ${basicAuth}` }, headers: { 'Authorization': `Basic ${basicAuth}` },
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
}); });
} catch (err) {
// Network error or timeout — retry unless this was the last attempt
if (attempt < FETCH_RETRY_ATTEMPTS - 1) { lastErr = err; continue; }
throw sanitizeError(err);
}
if (res.status === 403) { if (res.status === 403) {
throw Object.assign(new Error('SimpleFIN access has been revoked — please reconnect'), { code: 'SIMPLEFIN_REVOKED' }); throw Object.assign(new Error('SimpleFIN access has been revoked — please reconnect'), { code: 'SIMPLEFIN_REVOKED' });
} }
if (!res.ok) { if (!res.ok) {
throw new Error(`SimpleFIN fetch failed (HTTP ${res.status})`); const err = new Error(`SimpleFIN fetch failed (HTTP ${res.status})`);
// Retry transient server errors; surface client errors immediately
if (attempt < FETCH_RETRY_ATTEMPTS - 1 && res.status >= 500) { lastErr = err; continue; }
throw sanitizeError(err);
} }
let data;
try {
data = await res.json(); data = await res.json();
} catch (err) { } catch (err) {
throw sanitizeError(err); throw sanitizeError(err);
@ -119,10 +138,12 @@ async function fetchAccountsAndTransactions(accessUrl, sinceEpoch) {
.join('; '); .join('; ');
data._errlistSummary = msgs; data._errlistSummary = msgs;
} }
return data; return data;
} }
throw sanitizeError(lastErr);
}
function normalizeAccount(rawAccount, dataSourceId, userId) { function normalizeAccount(rawAccount, dataSourceId, userId) {
const balance = rawAccount.balance != null ? Math.round(parseFloat(rawAccount.balance) * 100) : null; const balance = rawAccount.balance != null ? Math.round(parseFloat(rawAccount.balance) * 100) : null;
const availableBalance = rawAccount['available-balance'] != null ? Math.round(parseFloat(rawAccount['available-balance']) * 100) : null; const availableBalance = rawAccount['available-balance'] != null ? Math.round(parseFloat(rawAccount['available-balance']) * 100) : null;