/** * Retry Utilities * * Utilities for implementing retry logic with exponential backoff * * @module retry */ /** * Retry configuration options */ export interface RetryConfig { /** * Maximum number of retry attempts * * @default 3 */ maxRetries: number; /** * Initial delay in milliseconds before first retry * * @default 1000 (1 second) */ initialDelay: number; /** * Maximum delay in milliseconds between retries * * @default 10000 (10 seconds) */ maxDelay?: number; /** * Backoff multiplier for exponential backoff * * @default 2 */ backoffMultiplier?: number; /** * Whether to add random jitter to delays * Helps prevent thundering herd in distributed systems * * @default true */ jitter?: boolean; /** * Custom function to determine if an error should be retried * * @param error - The error that occurred * @param attempt - Current attempt number (1-based) * @returns True if should retry, false otherwise */ shouldRetry?: (error: Error, attempt: number) => boolean; /** * Callback invoked before each retry attempt * * @param error - The error that occurred * @param attempt - Current attempt number (1-based) * @param delay - Delay before next attempt in milliseconds */ onRetry?: (error: Error, attempt: number, delay: number) => void; } /** * Default retry configuration */ export const DEFAULT_RETRY_CONFIG: Required< Omit > = { maxRetries: 3, initialDelay: 1000, maxDelay: 10000, backoffMultiplier: 2, jitter: true, }; /** * Calculate delay for retry attempt using exponential backoff * * @param attempt - Current attempt number (1-based) * @param config - Retry configuration * @returns Delay in milliseconds */ export function calculateRetryDelay( attempt: number, config: RetryConfig ): number { const { initialDelay, backoffMultiplier = 2, maxDelay = 10000, jitter = true, } = config; // Calculate exponential backoff: initialDelay * (backoffMultiplier ^ (attempt - 1)) let delay = initialDelay * Math.pow(backoffMultiplier, attempt - 1); // Cap at max delay delay = Math.min(delay, maxDelay); // Add jitter if enabled (randomize between 50% and 100% of calculated delay) if (jitter) { const jitterFactor = 0.5 + Math.random() * 0.5; // Random value between 0.5 and 1.0 delay = Math.floor(delay * jitterFactor); } return delay; } /** * Default shouldRetry function * Retries on network errors and 5xx server errors * * @param error - The error that occurred * @returns True if should retry */ export function defaultShouldRetry(error: Error): boolean { // Check if it's an Axios error with response if ('response' in error && error.response) { const response = error.response as { status?: number }; // Retry on 5xx server errors and 429 (rate limit) return ( (response.status !== undefined && response.status >= 500) || response.status === 429 ); } // Check if it's a network error (no response) if ('code' in error) { const code = (error as { code?: string }).code; // Retry on common network error codes return ( code === 'ECONNRESET' || code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNABORTED' ); } // Don't retry by default return false; } /** * Sleep for specified milliseconds * * @param ms - Milliseconds to sleep * @returns Promise that resolves after delay */ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Execute a function with retry logic * * @param fn - Async function to execute * @param config - Retry configuration * @returns Promise resolving to function result * @throws Last error if all retries fail * * @example * ```typescript * const result = await retryWithBackoff( * async () => { * const response = await axios.get('/api/data'); * return response.data; * }, * { * maxRetries: 3, * initialDelay: 1000, * onRetry: (error, attempt, delay) => { * console.log(`Retry attempt ${attempt} after ${delay}ms`); * } * } * ); * ``` */ export async function retryWithBackoff( fn: () => Promise, config: RetryConfig ): Promise { const fullConfig = { ...DEFAULT_RETRY_CONFIG, ...config }; const shouldRetry = config.shouldRetry || defaultShouldRetry; let lastError: Error | undefined; let attempt = 0; while (attempt <= fullConfig.maxRetries) { try { // First attempt is not a retry if (attempt === 0) { return await fn(); } // Calculate delay and wait const delay = calculateRetryDelay(attempt, fullConfig); // Call onRetry callback if provided if (config.onRetry && lastError) { config.onRetry(lastError, attempt, delay); } await sleep(delay); // Retry the function return await fn(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); // Check if we should retry const canRetry = attempt < fullConfig.maxRetries && shouldRetry(lastError, attempt + 1); if (!canRetry) { throw lastError; } attempt++; } } // This should never be reached, but TypeScript needs it throw lastError || new Error('Retry failed with unknown error'); } /** * Create a retry wrapper for a function * * @param fn - Function to wrap * @param config - Retry configuration * @returns Wrapped function with retry logic * * @example * ```typescript * const fetchWithRetry = withRetry( * async (url: string) => { * const response = await axios.get(url); * return response.data; * }, * { maxRetries: 3, initialDelay: 1000 } * ); * * const data = await fetchWithRetry('/api/data'); * ``` */ export function withRetry( fn: (...args: TArgs) => Promise, config: RetryConfig ): (...args: TArgs) => Promise { return async (...args: TArgs): Promise => { return retryWithBackoff(() => fn(...args), config); }; }