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