feat: Implement comprehensive web request handling with caching, retry, and interceptors
- Added cache strategies: NetworkFirst, CacheFirst, StaleWhileRevalidate, NetworkOnly, and CacheOnly. - Introduced InterceptorManager for managing request, response, and error interceptors. - Developed RetryManager for handling request retries with customizable backoff strategies. - Implemented RequestDeduplicator to prevent simultaneous identical requests. - Created timeout utilities for handling request timeouts. - Enhanced WebrequestClient to support global interceptors, caching, and retry logic. - Added convenience methods for common HTTP methods (GET, POST, PUT, DELETE) with JSON handling. - Established a fetch-compatible webrequest function for seamless integration. - Defined core type structures for caching, retry options, interceptors, and web request configurations.
This commit is contained in:
		
							
								
								
									
										199
									
								
								ts/retry/retry.manager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								ts/retry/retry.manager.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,199 @@ | ||||
| /** | ||||
|  * 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); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										67
									
								
								ts/retry/retry.strategies.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								ts/retry/retry.strategies.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| /** | ||||
|  * Retry backoff strategies | ||||
|  */ | ||||
|  | ||||
| import type { TBackoffStrategy } from '../webrequest.types.js'; | ||||
|  | ||||
| export interface IBackoffCalculator { | ||||
|   calculate(attempt: number, initialDelay: number, maxDelay: number): number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Exponential backoff strategy | ||||
|  * Delay increases exponentially: initialDelay * 2^attempt | ||||
|  */ | ||||
| export class ExponentialBackoff implements IBackoffCalculator { | ||||
|   calculate(attempt: number, initialDelay: number, maxDelay: number): number { | ||||
|     const delay = initialDelay * Math.pow(2, attempt - 1); | ||||
|     return Math.min(delay, maxDelay); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Linear backoff strategy | ||||
|  * Delay increases linearly: initialDelay * attempt | ||||
|  */ | ||||
| export class LinearBackoff implements IBackoffCalculator { | ||||
|   calculate(attempt: number, initialDelay: number, maxDelay: number): number { | ||||
|     const delay = initialDelay * attempt; | ||||
|     return Math.min(delay, maxDelay); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Constant backoff strategy | ||||
|  * Delay stays constant: initialDelay | ||||
|  */ | ||||
| export class ConstantBackoff implements IBackoffCalculator { | ||||
|   calculate(attempt: number, initialDelay: number, maxDelay: number): number { | ||||
|     return Math.min(initialDelay, maxDelay); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get backoff calculator for a given strategy | ||||
|  */ | ||||
| export function getBackoffCalculator( | ||||
|   strategy: TBackoffStrategy, | ||||
| ): IBackoffCalculator { | ||||
|   switch (strategy) { | ||||
|     case 'exponential': | ||||
|       return new ExponentialBackoff(); | ||||
|     case 'linear': | ||||
|       return new LinearBackoff(); | ||||
|     case 'constant': | ||||
|       return new ConstantBackoff(); | ||||
|     default: | ||||
|       return new ExponentialBackoff(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Add jitter to delay to prevent thundering herd | ||||
|  */ | ||||
| export function addJitter(delay: number, jitterFactor: number = 0.1): number { | ||||
|   const jitter = delay * jitterFactor * Math.random(); | ||||
|   return delay + jitter; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user