/** * Retry utilities with exponential backoff */ export class RetryError extends Error { constructor( message: string, public readonly attempts: number, public readonly lastError: Error ) { super(message); this.name = 'RetryError'; } } export interface RetryOptions { /** Maximum number of retry attempts (default: 3) */ maxAttempts?: number; /** Initial delay in milliseconds (default: 1000) */ initialDelay?: number; /** Maximum delay in milliseconds (default: 30000) */ maxDelay?: number; /** Backoff multiplier (default: 2) */ backoffFactor?: number; /** Function to determine if error is retryable */ shouldRetry?: (error: Error, attempt: number) => boolean; /** Callback on each retry */ onRetry?: (error: Error, attempt: number, nextDelay: number) => void; } /** * Execute a function with automatic retry and exponential backoff * * Retries failed operations with increasing delays between attempts. * Useful for handling transient failures in network requests. * * @param fn - Async function to execute * @param options - Retry configuration options * @returns Promise resolving to the function's return value * @throws {RetryError} If all retry attempts fail * * @example * ```typescript * const data = await withRetry( * () => fetch('https://api.example.com/data'), * { * maxAttempts: 3, * initialDelay: 1000, * shouldRetry: (err) => err.message.includes('timeout') * } * ); * ``` */ export async function withRetry( fn: () => Promise, options: RetryOptions = {} ): Promise { const { maxAttempts = 3, initialDelay = 1000, maxDelay = 30000, backoffFactor = 2, shouldRetry = () => true, onRetry, } = options; let lastError: Error = new Error('No attempts made'); for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt === maxAttempts || !shouldRetry(lastError, attempt)) { break; } const delay = Math.min( initialDelay * Math.pow(backoffFactor, attempt - 1), maxDelay ); onRetry?.(lastError, attempt, delay); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw new RetryError( `Failed after ${maxAttempts} attempts: ${lastError.message}`, maxAttempts, lastError ); } /** * Determine if a GitHub API error should be retried * * Implements retry logic specific to GitHub API errors: * - Retries on rate limits (429) and server errors (5xx) * - Retries on network errors * - Does not retry on client errors (4xx except 429) * * @param error - The error that occurred * @param _attempt - The attempt number (unused) * @returns true if the error is retryable, false otherwise * * @example * ```typescript * const result = await withRetry( * () => githubApiCall(), * { * shouldRetry: shouldRetryGitHubError * } * ); * ``` */ export function shouldRetryGitHubError(error: Error, _attempt: number): boolean { // Prefer the structured status code if the error carries one (e.g. // GitHubAPIError). The previous string-match approach was brittle — // GitHub error messages don't include numeric status codes by default. const status = (error as { status?: unknown }).status; if (typeof status === "number") { if (status === 429) return true; // Rate limit if (status >= 500 && status < 600) return true; // Server errors if (status >= 400 && status < 500) return false; // Other client errors return false; } const message = error.message.toLowerCase(); if (message.includes('rate limit')) return true; // Network/transport errors are typically retryable if ( message.includes('network') || message.includes('econnreset') || message.includes('etimedout') || message.includes('fetch failed') ) { return true; } // Default: retry on unknown errors (likely transient) return true; }