import { TransferAbortError, TransferTransportError } from './errors.js'; export interface RetryPolicy { maxAttempts: number; /** Base delay in ms; doubled per attempt with jitter. */ baseDelayMs: number; /** Hard cap on a single delay step. */ maxDelayMs: number; /** Jitter factor in [0, 1); 0 = deterministic, 0.5 = ±25 % spread. */ jitter: number; } export const DEFAULT_RETRY: RetryPolicy = { maxAttempts: 5, baseDelayMs: 250, maxDelayMs: 30_000, jitter: 0.25, }; export interface RetryContext { attempt: number; lastError: unknown; willRetryInMs: number; } /** * Run an idempotent operation with exponential backoff. Aborts immediately * via the supplied signal. `onAttempt` runs BEFORE each retry sleep. * * The operation is treated as retryable unless it throws an * `AbortError`/`TransferAbortError` or a non-network error wrapped in * `TransferTransportError` with a 4xx status code (those are * deterministic and retrying won't help). */ export async function withRetry( op: (attempt: number) => Promise, options?: { policy?: RetryPolicy; signal?: AbortSignal; onAttempt?: (ctx: RetryContext) => void; isRetryable?: (err: unknown) => boolean; }, ): Promise { const policy = options?.policy ?? DEFAULT_RETRY; const signal = options?.signal; const onAttempt = options?.onAttempt; const isRetryable = options?.isRetryable ?? defaultIsRetryable; let lastError: unknown; for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) { if (signal?.aborted) throw new TransferAbortError('Aborted before attempt'); try { return await op(attempt); } catch (err) { lastError = err; if (signal?.aborted) throw new TransferAbortError('Aborted during attempt'); if (!isRetryable(err) || attempt === policy.maxAttempts) throw err; const delay = computeDelay(attempt, policy); onAttempt?.({ attempt, lastError: err, willRetryInMs: delay }); await sleep(delay, signal); } } throw lastError ?? new Error('withRetry: unreachable'); } function defaultIsRetryable(err: unknown): boolean { if (err instanceof TransferAbortError) return false; if (err instanceof TransferTransportError) { if (err.statusCode === undefined) return true; // network-level failure if (err.statusCode >= 500) return true; if (err.statusCode === 408 || err.statusCode === 429) return true; return false; } // Network errors / DOMException['AbortError'] if (typeof err === 'object' && err !== null) { const name = (err as { name?: unknown }).name; if (name === 'AbortError') return false; if (name === 'TypeError') return true; // fetch network failure } return true; } function computeDelay(attempt: number, policy: RetryPolicy): number { const base = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1)); const half = base * policy.jitter * 0.5; return Math.max(0, base - half + Math.random() * half * 2); } function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new TransferAbortError('Aborted')); return; } const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); function onAbort() { clearTimeout(timer); reject(new TransferAbortError('Aborted')); } signal?.addEventListener('abort', onAbort, { once: true }); }); }