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:
2025-10-20 09:59:24 +00:00
parent e228ed4ba0
commit 54afcc46e2
30 changed files with 18693 additions and 4031 deletions

199
ts/retry/retry.manager.ts Normal file
View 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);
}
}