/** * Retry manager for handling request retries */ import * as plugins from '../webrequest.plugins.js'; import type { IRetryOptions } from '../webrequest.types.js'; import { getBackoffCalculator, addJitter } from './retry.strategies.js'; export class RetryManager { private options: Required; constructor(options: IRetryOptions = {}) { this.options = { maxAttempts: options.maxAttempts ?? 3, backoff: options.backoff ?? 'exponential', initialDelay: options.initialDelay ?? 1000, maxDelay: options.maxDelay ?? 30000, retryOn: options.retryOn ?? [408, 429, 500, 502, 503, 504], onRetry: options.onRetry ?? (() => {}), }; } /** * Execute a request with retry logic */ public async execute( executeFn: () => Promise, shouldRetryFn?: (error: any, attempt: number) => boolean, ): Promise { let lastError: Error; let lastResponse: Response | undefined; for (let attempt = 1; attempt <= this.options.maxAttempts; attempt++) { try { const result = await executeFn(); // Check if result is a Response and if we should retry based on status if (result instanceof Response) { if (this.shouldRetryResponse(result)) { lastResponse = result; // If this is the last attempt, return the failed response if (attempt === this.options.maxAttempts) { return result; } // Calculate delay and retry const delay = this.calculateDelay(attempt); this.options.onRetry( attempt, new Error(`HTTP ${result.status}`), delay, ); await this.delay(delay); continue; } } // Success return result; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); // Check if we should retry const shouldRetry = shouldRetryFn ? shouldRetryFn(error, attempt) : this.shouldRetryError(error); // If this is the last attempt or we shouldn't retry, throw if (attempt === this.options.maxAttempts || !shouldRetry) { throw lastError; } // Calculate delay and retry const delay = this.calculateDelay(attempt); this.options.onRetry(attempt, lastError, delay); await this.delay(delay); } } // This should never be reached, but TypeScript needs it throw lastError! || new Error('Max retry attempts reached'); } /** * Execute with multiple fallback URLs */ public async executeWithFallbacks( urls: string[], requestInit: RequestInit, fetchFn: (url: string, init: RequestInit) => Promise, ): Promise { if (urls.length === 0) { throw new Error('No URLs provided for fallback execution'); } let lastError: Error | undefined; const failedUrls: string[] = []; for (const url of urls) { try { // Try the URL with retry logic const response = await this.execute(async () => { return await fetchFn(url, requestInit); }); // If successful (status < 400), return if (response.status < 400) { return response; } // If 4xx client error (except 408 timeout), don't try other URLs if ( response.status >= 400 && response.status < 500 && response.status !== 408 ) { return response; } // Server error or timeout, try next URL failedUrls.push(url); lastError = new Error(`Request failed with status ${response.status}`); } catch (error) { failedUrls.push(url); lastError = error instanceof Error ? error : new Error(String(error)); } } // All URLs failed throw new Error( `All URLs failed: ${failedUrls.join(', ')}. Last error: ${lastError?.message || 'Unknown error'}`, ); } /** * Check if we should retry based on response status */ private shouldRetryResponse(response: Response): boolean { const retryOn = this.options.retryOn; if (typeof retryOn === 'function') { return retryOn(response); } if (Array.isArray(retryOn)) { return retryOn.includes(response.status); } return false; } /** * Check if we should retry based on error */ private shouldRetryError(error: any): boolean { // Network errors should be retried if (error instanceof TypeError && error.message.includes('fetch')) { return true; } // Timeout errors should be retried if (error.name === 'AbortError' || error.message.includes('timeout')) { return true; } // If retryOn is a function, use it const retryOn = this.options.retryOn; if (typeof retryOn === 'function') { return retryOn(undefined as any, error); } return false; } /** * Calculate delay for next retry */ private calculateDelay(attempt: number): number { const calculator = getBackoffCalculator(this.options.backoff); const baseDelay = calculator.calculate( attempt, this.options.initialDelay, this.options.maxDelay, ); // Add jitter to prevent thundering herd return addJitter(baseDelay); } /** * Delay execution */ private async delay(ms: number): Promise { await plugins.smartdelay.delayFor(ms); } }