const DEFAULT_BACKOFF_MS = 150;
export interface FetchWithRetryConfig {
timeoutMs?: number;
retries?: number;
fetchImpl?: typeof fetch;
backoffMs?: number;
}
export async function fetchWithRetry(
url: string | URL,
options: RequestInit = {},
config: FetchWithRetryConfig = {},
): Promise<Response> {
const {
timeoutMs = 2000,
retries = 0,
fetchImpl = globalThis.fetch,
backoffMs = DEFAULT_BACKOFF_MS,
} = config;
if (typeof fetchImpl !== 'function') {
throw new Error('fetch implementation is required');
}
let attempt = 0;
let lastError: unknown;
while (attempt <= retries) {
const attemptNumber = attempt;
try {
const response = await attemptFetch(url, options, { timeoutMs, fetchImpl });
if (!response.ok) {
const error = new Error(`Request failed with status ${response.status}`);
(error as Error & { status?: number }).status = response.status;
throw error;
}
return response;
} catch (error) {
lastError = error;
if (attemptNumber === retries) {
throw lastError;
}
const delayMs = backoffMs * 2 ** attemptNumber;
await delay(delayMs);
attempt += 1;
}
}
throw lastError ?? new Error('fetchWithRetry failed');
}
interface AttemptFetchOptions {
timeoutMs: number;
fetchImpl: typeof fetch;
}
async function attemptFetch(
url: string | URL,
options: RequestInit,
{ timeoutMs, fetchImpl }: AttemptFetchOptions,
): Promise<Response> {
const controller = new AbortController();
const { signal: userSignal, ...rest } = options;
let timeoutId: NodeJS.Timeout | undefined;
let userAbortHandler: (() => void) | undefined;
try {
timeoutId = setTimeout(() => {
controller.abort(new Error('timeout'));
}, timeoutMs);
if (userSignal) {
if (userSignal.aborted) {
const reason = (userSignal as AbortSignal & { reason?: unknown }).reason ?? new Error('Aborted by caller');
controller.abort(reason);
} else {
userAbortHandler = () => {
const reason = (userSignal as AbortSignal & { reason?: unknown }).reason ?? new Error('Aborted by caller');
controller.abort(reason);
};
userSignal.addEventListener('abort', userAbortHandler, { once: true });
}
}
return await fetchImpl(url, { ...rest, signal: controller.signal });
} catch (error) {
if ((error as Error | DOMException)?.name === 'AbortError' || (error as Error)?.message === 'timeout') {
const timeoutError = new Error('Request timed out');
(timeoutError as Error & { code?: string }).code = 'ETIMEDOUT';
throw timeoutError;
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (userSignal && userAbortHandler) {
userSignal.removeEventListener('abort', userAbortHandler);
}
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}