Repository Public
1import { ProviderError } from './errors.js';23export interface RetryOptions {4 maxAttempts: number;5 sleep: (ms: number, signal?: AbortSignal) => Promise<void>;6 random: () => number;7 signal?: AbortSignal;8}910export const DEFAULT_MAX_ATTEMPTS = 6;11const BASE_DELAY_MS = 1_000;12const MAX_BACKOFF_MS = 30_000;13/** A server may ask for a long pause; beyond this we give up the wait and let the step fail. */14const MAX_RETRY_AFTER_MS = 120_000;1516/** Exponential backoff with full jitter on the upper half: attempt 1 → 0.5–1 s, 2 → 1–2 s, … capped at 30 s. */17export function backoffMs(attempt: number, random: () => number): number {18 const ceiling = Math.min(MAX_BACKOFF_MS, BASE_DELAY_MS * 2 ** (attempt - 1));19 return Math.round(ceiling * (0.5 + 0.5 * random()));20}2122export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {23 return new Promise((resolve, reject) => {24 if (signal?.aborted) {25 reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));26 return;27 }28 const onAbort = (): void => {29 clearTimeout(timer);30 reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'));31 };32 const timer = setTimeout(() => {33 signal?.removeEventListener('abort', onAbort);34 resolve();35 }, ms);36 signal?.addEventListener('abort', onAbort, { once: true });37 });38}3940/**41 * Runs `fn` until it succeeds, throws a non-retryable `ProviderError`, or attempts are exhausted.42 * `fn` must throw `ProviderError` only (adapters map SDK errors before rethrowing).43 */44export async function withRetries<T>(45 fn: (attempt: number) => Promise<T>,46 opts: RetryOptions,47 onAbort: (cause: unknown, attempt: number) => ProviderError,48): Promise<{ value: T; attempts: number }> {49 for (let attempt = 1; ; attempt++) {50 if (opts.signal?.aborted) throw onAbort(opts.signal.reason, attempt - 1);51 try {52 return { value: await fn(attempt), attempts: attempt };53 } catch (err) {54 if (!(err instanceof ProviderError)) throw err;55 err.attempts = attempt;56 if (!err.retryable || attempt >= opts.maxAttempts) throw err;57 const wait = err.retryAfterMs !== null58 ? Math.min(err.retryAfterMs, MAX_RETRY_AFTER_MS)59 : backoffMs(attempt, opts.random);60 try {61 await opts.sleep(wait, opts.signal);62 } catch (cause) {63 throw onAbort(cause, attempt);64 }65 }66 }67}68