export interface RetryOptions { maxRetries?: number; baseDelayMs?: number; maxDelayMs?: number; jitter?: boolean; retryable?: (error: Error) => boolean; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function defaultRetryable(error: Error): boolean { const msg = error.message.toLowerCase(); // Retry on rate limits, timeouts, and transient network errors if (msg.includes("429")) return true; if (msg.includes("rate limit")) return true; if (msg.includes("timeout")) return true; if (msg.includes("econnreset")) return true; if (msg.includes("etimedout")) return true; if (msg.includes("enetunreach")) return true; if (msg.includes("fetch failed")) return true; return false; } export async function withRetry( fn: () => Promise, options: RetryOptions = {} ): Promise { const { maxRetries = 3, baseDelayMs = 1000, maxDelayMs = 30000, jitter = true, retryable = defaultRetryable, } = options; let lastError: Error; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (err: any) { lastError = err instanceof Error ? err : new Error(String(err)); if (attempt === maxRetries || !retryable(lastError)) { throw lastError; } // Exponential backoff: 1s, 2s, 4s, ... capped at maxDelayMs let delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs); // Add jitter: ±25% randomization to prevent thundering herd if (jitter) { const variance = delay * 0.25; delay = delay - variance + Math.random() * variance * 2; } await sleep(Math.round(delay)); } } throw lastError!; }