src/transport/retry.ts
v0.2.0 · 3.6 KB
// Retry with exponential backoff and jitter. Retrying is a policy decision: queries are idempotent and may
// retry on any transient error; an execute may retry on 429 always, and on other transient errors only
// when the caller marked it idempotent (cancels, full reduce-only closes).
/** Backoff parameters. */
export interface BackoffOptions {
/** Delay before the first retry, ms. Default 250. */
baseDelayMs?: number;
/** Multiplier per attempt. Default 2.5. */
factor?: number;
/** Relative jitter, 0.4 = +-40%. Default 0.4. */
jitter?: number;
/** Upper bound for a single delay, ms. Default: no cap. */
maxDelayMs?: number;
/** Random source in [0, 1). Default `Math.random`. */
random?: () => number;
}
/** Retry parameters (backoff + attempt budget). */
export interface RetryOptions extends BackoffOptions {
/** Retries after the first attempt. Default 3 (4 attempts in total). */
retries?: number;
}
export const DEFAULT_RETRY = Object.freeze({ retries: 3, baseDelayMs: 250, factor: 2.5, jitter: 0.4 });
/** Delay before retry number `attempt` (0-based): `base * factor^attempt * (1 +- jitter)`, capped, never negative. */
export function backoffDelayMs(attempt: number, opts: BackoffOptions = {}): number {
const base = opts.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs;
const factor = opts.factor ?? DEFAULT_RETRY.factor;
const jitter = Math.min(1, Math.max(0, opts.jitter ?? DEFAULT_RETRY.jitter));
const random = opts.random ?? Math.random;
const n = Math.max(0, Math.floor(attempt));
const raw = base * factor ** n * (1 + jitter * (random() * 2 - 1));
const capped = opts.maxDelayMs !== undefined ? Math.min(opts.maxDelayMs, raw) : raw;
return Math.max(0, Math.round(capped));
}
/** Options for {@link withRetry}. */
export interface WithRetryOptions extends RetryOptions {
/** Decides whether an error is retryable (pick by action class). */
shouldRetry: (err: unknown, attempt: number) => boolean;
/** Aborts the backoff sleep; an aborted signal also stops further retries. */
signal?: AbortSignal;
/** Called before sleeping for a retry. */
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
}
function abortReason(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException('This operation was aborted', 'AbortError');
}
/** Promise-based sleep that rejects with the signal's reason when aborted. */
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(abortReason(signal));
return new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(abortReason(signal as AbortSignal));
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
/**
* Runs `fn` and retries it while `shouldRetry` approves, sleeping {@link backoffDelayMs} between attempts.
* Throws the last error once the budget is spent. `fn` receives the 0-based attempt.
*/
export async function withRetry<T>(fn: (attempt: number) => Promise<T>, opts: WithRetryOptions): Promise<T> {
const retries = Math.max(0, Math.floor(opts.retries ?? DEFAULT_RETRY.retries));
for (let attempt = 0; ; attempt++) {
try {
return await fn(attempt);
} catch (err) {
if (opts.signal?.aborted) throw err;
if (attempt >= retries || !opts.shouldRetry(err, attempt)) throw err;
const delay = backoffDelayMs(attempt, opts);
opts.onRetry?.(err, attempt, delay);
await sleep(delay, opts.signal);
}
}
}