- 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.
		
			
				
	
	
		
			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);
 | |
|   }
 | |
| }
 |