export interface RetryOptions { attempts?: number; minDelayMs?: number; maxDelayMs?: number; factor?: number; jitter?: number; signal?: AbortSignal; retryIf?: (error: unknown, attempt: number) => boolean | Promise; onRetry?: (error: unknown, attempt: number, delayMs: number) => void | Promise; } function nonNegative(value: number, label: string): number { if (!Number.isFinite(value) || value < 0) { throw new RangeError(`${label} must be a non-negative number`); } return value; } export function backoffDelay( attempt: number, options: Pick = {}, ): number { if (!Number.isInteger(attempt) || attempt < 1) { throw new RangeError("attempt must be a positive integer"); } const min = nonNegative(options.minDelayMs ?? 100, "minDelayMs"); const max = nonNegative(options.maxDelayMs ?? 30_000, "maxDelayMs"); if (max < min) throw new RangeError("maxDelayMs must be at least minDelayMs"); const factor = options.factor ?? 2; if (!Number.isFinite(factor) || factor < 1) { throw new RangeError("factor must be at least 1"); } const jitter = Math.min(1, Math.max(0, options.jitter ?? 0.2)); const raw = Math.min(max, min * factor ** Math.max(0, attempt - 1)); return Math.round(raw * (1 - jitter + Math.random() * jitter * 2)); } export function sleep(ms: number, signal?: AbortSignal): Promise { nonNegative(ms, "sleep duration"); if (signal?.aborted) { return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError")); } return new Promise((resolve, reject) => { const abort = (): void => { clearTimeout(timer); reject(signal?.reason ?? new DOMException("Aborted", "AbortError")); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, ms); signal?.addEventListener("abort", abort, { once: true }); }); } export async function retry( operation: (attempt: number, signal?: AbortSignal) => Promise, options: RetryOptions = {}, ): Promise { const attempts = options.attempts ?? 3; if (!Number.isInteger(attempts) || attempts < 1) { throw new RangeError("attempts must be a positive integer"); } let last: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { options.signal?.throwIfAborted(); try { return await operation(attempt, options.signal); } catch (error) { last = error; const retryAllowed = options.retryIf ? await options.retryIf(error, attempt) : true; if (attempt >= attempts || !retryAllowed) throw error; const delay = backoffDelay(attempt, options); await options.onRetry?.(error, attempt, delay); await sleep(delay, options.signal); } } throw last; } export async function withTimeout( promise: Promise, timeoutMs: number, message = "Operation timed out", signal?: AbortSignal, ): Promise { nonNegative(timeoutMs, "timeoutMs"); signal?.throwIfAborted(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(new Error(message)), timeoutMs); const abort = (): void => controller.abort(signal?.reason); signal?.addEventListener("abort", abort, { once: true }); try { return await Promise.race([ promise, new Promise((_resolve, reject) => { controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true, }); }), ]); } finally { clearTimeout(timeout); signal?.removeEventListener("abort", abort); } } export function stableStringify(value: unknown): string { const ancestors = new Set(); const normalize = (input: unknown): unknown => { if (!input || typeof input !== "object") return input; if (input instanceof Date) return input.toISOString(); if (ancestors.has(input)) { throw new TypeError("Cannot stringify circular structure"); } ancestors.add(input); try { if (Array.isArray(input)) return input.map(normalize); const record = input as Record; return Object.fromEntries( Object.keys(record) .sort() .map((key) => [key, normalize(record[key])]), ); } finally { ancestors.delete(input); } }; return JSON.stringify(normalize(value)); } export function safeJsonParse(value: string, fallback: T): T { try { return JSON.parse(value) as T; } catch { return fallback; } } export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } export function once any>(fn: T): T { let called = false; let result: ReturnType; return ((...args: Parameters) => { if (!called) { called = true; result = fn(...args); } return result; }) as T; }