/** * Retry policy for LLM calls. * * Exponential backoff with full jitter, base 1000ms, cap 16000ms. Only * retries on transient HTTP statuses (408, 425, 429, 5xx, 529). Honours a * `Retry-After` hint up to 60 seconds; longer waits fail fast. Never retries * on AbortError. */ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504, 529]); const BASE_DELAY_MS = 1000; const CAP_DELAY_MS = 16000; const MAX_RETRY_AFTER_MS = 60 * 1000; export class RetryableError extends Error { constructor(status, retryAfterMs = null, message) { super(message || `Retryable HTTP ${status}`); this.name = 'RetryableError'; this.status = status; this.retryAfterMs = retryAfterMs; } } export function isRetryableStatus(status) { return RETRYABLE_STATUSES.has(status); } function backoffWithJitter(attempt) { const exp = Math.min(CAP_DELAY_MS, BASE_DELAY_MS * 2 ** attempt); return Math.floor(Math.random() * exp); } /** * Parse a `Retry-After` header. Returns null when absent or unusable, or * a millisecond delay otherwise. Supports both seconds and HTTP-date forms. */ export function parseRetryAfter(value, now = Date.now()) { if (value == null) return null; const s = String(value).trim(); if (!s) return null; if (/^\d+$/.test(s)) return Number(s) * 1000; const dateMs = Date.parse(s); if (Number.isFinite(dateMs)) { const delta = dateMs - now; return delta > 0 ? delta : 0; } return null; } function sleep(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(t); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); }; signal?.addEventListener('abort', onAbort, { once: true }); }); } /** * Run `fn(attempt)` with retry. `fn` may throw a `RetryableError` to request * a retry, or any other error to fail immediately. * * @template T * @param {(attempt:number) => Promise} fn * @param {{ maxRetries?: number, signal?: AbortSignal }} [opts] * @returns {Promise} */ export async function withRetry(fn, { maxRetries = 4, signal } = {}) { let attempt = 0; for (;;) { if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError'); try { return await fn(attempt); } catch (err) { if (err?.name === 'AbortError') throw err; const retryable = err instanceof RetryableError; if (!retryable || attempt >= maxRetries) throw err; let delayMs; if (err.retryAfterMs != null) { if (err.retryAfterMs > MAX_RETRY_AFTER_MS) throw err; delayMs = err.retryAfterMs; } else { delayMs = backoffWithJitter(attempt); } await sleep(delayMs, signal); attempt++; } } }