/** * Retry policy — configurable exponential backoff with jitter. * Covers: 5xx server errors, network timeouts, ECONNREFUSED, ECONNRESET. * NOT retried: 4xx client errors (except 429 — handled separately), redirects. */ export interface RetryPolicyOpts { maxRetries?: number; // default 3 baseDelayMs?: number; // default 1000 maxDelayMs?: number; // default 30000 jitterMs?: number; // default 500 retryOn?: number[]; // HTTP status codes to retry (default: [429, 500, 502, 503, 504]) } export interface RetryResult { value: T; attempts: number; totalDelayMs: number; } const DEFAULT_RETRY_STATUSES = [429, 500, 502, 503, 504]; function shouldRetry(error: any, retryOn: number[]): boolean { // Network-level errors if (error?.code && ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EPIPE'].includes(error.code)) return true; // Playwright navigation timeout if (error?.message && /timeout|net::ERR_/i.test(error.message)) return true; // HTTP status errors if (typeof error?.status === 'number') return retryOn.includes(error.status); return false; } function delay(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } export async function withRetry( fn: (attempt: number) => Promise, opts: RetryPolicyOpts = {}, ): Promise> { const { maxRetries = 3, baseDelayMs = 1_000, maxDelayMs = 30_000, jitterMs = 500, retryOn = DEFAULT_RETRY_STATUSES, } = opts; let lastError: any; let totalDelayMs = 0; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const value = await fn(attempt); return { value, attempts: attempt + 1, totalDelayMs }; } catch (err) { lastError = err; if (attempt === maxRetries) break; if (!shouldRetry(err, retryOn)) throw err; // non-retryable — fail fast const backoff = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt)); const jitter = Math.random() * jitterMs; const waitMs = Math.round(backoff + jitter); totalDelayMs += waitMs; await delay(waitMs); } } throw lastError; } /** Wraps fetch() with retry policy */ export async function fetchWithRetry( url: string, init: RequestInit = {}, retryOpts: RetryPolicyOpts = {}, ): Promise { const result = await withRetry(async () => { const res = await fetch(url, init); if (retryOpts.retryOn?.includes(res.status) ?? DEFAULT_RETRY_STATUSES.includes(res.status)) { const err: any = new Error(`HTTP ${res.status}`); err.status = res.status; throw err; } return res; }, retryOpts); return result.value; }