interface BackoffConfig {
delays?: number[];
jitterMin?: number;
jitterMax?: number;
retryCondition?: (error: Error | null, response: Response | null) => boolean;
signal?: AbortSignal;
}
class FetchWithBackoff {
private delays: number[];
private jitterMin: number;
private jitterMax: number;
private retryCondition: (error: Error | null, response: Response | null) => boolean;
constructor(config: BackoffConfig = {}) {
this.delays = config.delays || [
1000,
2000,
4000,
8000,
16000,
32000,
60000,
60000,
60000,
60000,
];
this.jitterMin = config.jitterMin ?? 0.75;
this.jitterMax = config.jitterMax ?? 1.25;
this.retryCondition = config.retryCondition || this.defaultRetryCondition;
}
private defaultRetryCondition(error: Error | null, response: Response | null): boolean {
// Retry on network errors
if (error && !response) return true;
if (response) {
return response.status >= 500 || response.status === 429;
}
return false;
}
private applyJitter(delay: number): number {
const jitterRange = this.jitterMax - this.jitterMin;
const jitterFactor = this.jitterMin + Math.random() * jitterRange;
return Math.round(delay * jitterFactor);
}
async fetch(url: string, options: RequestInit = {}): Promise<Response> {
let lastError: Error | null = null;
let lastResponse: Response | null = null;
for (let i = 0; i < this.delays.length; i++) {
const isLastAttempt = i === this.delays.length - 1;
try {
if (options.signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const response = await fetch(url, options);
if (response.ok || !this.retryCondition(null, response)) {
return response;
}
lastResponse = response;
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
} catch (error) {
lastError = error as Error;
lastResponse = null;
if (!this.retryCondition(lastError, null)) {
throw lastError;
}
}
if (isLastAttempt) {
throw lastError || new Error('Unknown error occurred');
}
const baseDelay = this.delays[i];
const jitteredDelay = this.applyJitter(baseDelay);
console.log(`Attempt ${i + 1} failed. Retrying in ${jitteredDelay}ms...`);
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(resolve, jitteredDelay);
const onAbort = () => {
clearTimeout(timeoutId);
reject(new DOMException('Aborted', 'AbortError'));
};
if (options.signal) {
if (options.signal.aborted) {
clearTimeout(timeoutId);
reject(new DOMException('Aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', onAbort, { once: true });
setTimeout(() => {
options.signal?.removeEventListener('abort', onAbort);
}, jitteredDelay);
}
});
}
throw new Error(`Failed after ${this.delays.length} attempts`);
}
}