/** * Retry utility with exponential backoff */ export interface RetryOptions { maxRetries?: number initialDelay?: number maxDelay?: number backoffMultiplier?: number retryable?: (error: Error) => boolean } const DEFAULT_OPTIONS: Required = { maxRetries: 3, initialDelay: 1000, maxDelay: 10000, backoffMultiplier: 2, retryable: () => true, } /** * Retry a function with exponential backoff */ export function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { const opts = { ...DEFAULT_OPTIONS, ...options } const attempt = (current: number): Promise => { return fn().catch((error) => { const normalizedError = error instanceof Error ? error : new Error(String(error)) if (!opts.retryable(normalizedError) || current === opts.maxRetries) { throw normalizedError } const delay = Math.min(opts.initialDelay * Math.pow(opts.backoffMultiplier, current), opts.maxDelay) return new Promise((resolve) => { setTimeout(() => { resolve(attempt(current + 1)) }, delay) }) }) } return attempt(0).catch((error) => { throw error ?? new Error('Retry failed') }) } /** * Check if an error is a network error that should be retried */ export function isRetryableNetworkError(error: Error): boolean { // Network errors if (error.message.includes('network') || error.message.includes('fetch')) { return true } // Timeout errors if (error.message.includes('timeout') || error.message.includes('timed out')) { return true } // Connection errors if (error.message.includes('ECONNRESET') || error.message.includes('ECONNREFUSED')) { return true } // Rate limiting (429) if (error.message.includes('429') || error.message.includes('rate limit')) { return true } // Server errors (5xx) if (error.message.includes('50')) { return true } return false }