200 lines
		
	
	
		
			5.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			200 lines
		
	
	
		
			5.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * 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<IRetryOptions>; | ||
|  | 
 | ||
|  |   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<T>( | ||
|  |     executeFn: () => Promise<T>, | ||
|  |     shouldRetryFn?: (error: any, attempt: number) => boolean, | ||
|  |   ): Promise<T> { | ||
|  |     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<Response>, | ||
|  |   ): Promise<Response> { | ||
|  |     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<void> { | ||
|  |     await plugins.smartdelay.delayFor(ms); | ||
|  |   } | ||
|  | } |