/** * Generic exponential-backoff retry wrapper. * * Usage: * const result = await withRetry(() => fetch(...), { retries: 3 }); * * Defaults: * retries 3 (up to 3 re-attempts after the first failure → 4 total calls) * baseDelayMs 1000 (delays: 1000, 2000, 4000 ms) * isRetryable network errors + HTTP 5xx-ish status codes * sleep real setTimeout-based sleep */ /** Shape of errors thrown by native-* transports: { status?: number } */ interface StatusError { status?: number; } function hasStatus(err: unknown): err is StatusError { return typeof err === 'object' && err !== null && 'status' in err; } /** * Default retryability predicate. * * Retries on: * - Network-level errors (TypeError: fetch failed, ECONNRESET, ETIMEDOUT, …) * - Responses that carry an HTTP status ≥ 500 (server-side faults) * * Does NOT retry on: * - HTTP 4xx (caller error — retrying won't help) * - Errors with no recognisable network/status shape */ export function defaultIsRetryable(err: unknown): boolean { if (err instanceof TypeError) { // fetch() throws TypeError for network-level failures return true; } if (err instanceof Error) { const msg = err.message.toLowerCase(); if ( msg.includes('econnreset') || msg.includes('econnrefused') || msg.includes('etimedout') || msg.includes('network') || msg.includes('socket') ) { return true; } } if (hasStatus(err) && typeof err.status === 'number') { return err.status >= 500; } return false; } function defaultSleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export interface WithRetryOptions { /** Total number of re-attempts after the first failure (default 3). */ retries?: number; /** Base delay in ms; delay for attempt i = baseDelayMs * 2^i (default 1000). */ baseDelayMs?: number; /** Return true to retry this error; false to rethrow immediately (default: defaultIsRetryable). */ isRetryable?: (err: unknown) => boolean; /** Called before each sleep so callers can log/instrument (attempt is 1-indexed). */ onRetry?: (attempt: number, delayMs: number, err: unknown) => void; /** Injectable sleep — override in tests to avoid real waits. */ sleep?: (ms: number) => Promise; } /** Minimal response shape the transient-retry helper inspects. */ interface RetryableResponse { ok: boolean; status: number; } /** * Calls a fetch-like function once and, when the response carries an HTTP 5xx * status, throws a {@link StatusError}-shaped error so {@link withRetry}'s * default predicate retries it. A 4xx (or any other non-5xx) response is * returned unchanged so the caller's existing non-ok handling runs verbatim on * the final value. Network-level throws (TypeError / ECONNRESET / …) propagate * to `withRetry` and are retried by the default predicate as well. * * Intended to wrap the single `await fetchImpl(...)` in a native transport's * submit/poll choke point: * * const response = await withRetry(() => fetchTransientRetry(fetchImpl, url, init)); */ export async function fetchTransientRetry( fetchImpl: (input: string, init?: I) => Promise, url: string, init?: I, ): Promise { const response = await fetchImpl(url, init); if (!response.ok && response.status >= 500) { const err: StatusError & Error = Object.assign( new Error(`transient HTTP ${response.status}`), { status: response.status }, ); throw err; } return response; } /** * Calls `fn` and retries with exponential backoff on retryable errors. * * @param fn The async operation to attempt. * @param opts Retry configuration (all optional). * @returns Resolves with `fn`'s return value on success. * @throws The last error after all retries are exhausted, or * the first non-retryable error immediately. */ export async function withRetry( fn: () => Promise, opts?: WithRetryOptions, ): Promise { const retries = opts?.retries ?? 3; const baseDelayMs = opts?.baseDelayMs ?? 1000; const isRetryable = opts?.isRetryable ?? defaultIsRetryable; const onRetry = opts?.onRetry; const sleep = opts?.sleep ?? defaultSleep; let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { try { return await fn(); } catch (err) { lastError = err; // On the final attempt or a non-retryable error, stop immediately. if (attempt === retries || !isRetryable(err)) { throw err; } const delayMs = baseDelayMs * Math.pow(2, attempt); onRetry?.(attempt + 1, delayMs, err); await sleep(delayMs); } } // Unreachable — loop always throws or returns — but satisfies TS. throw lastError; }