86 行
2.6 KiB
TypeScript
86 行
2.6 KiB
TypeScript
type FetchRetryOptions = {
|
|
timeoutMs: number;
|
|
retries?: number;
|
|
retryStatuses?: number[];
|
|
retryMethods?: string[];
|
|
baseDelayMs?: number;
|
|
};
|
|
|
|
const DEFAULT_RETRY_STATUSES = [408, 425, 429, 502, 503, 504];
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function shouldRetryResponse(method: string, response: Response, options: FetchRetryOptions) {
|
|
const allowedMethods = options.retryMethods ?? ["GET", "HEAD"];
|
|
const retryStatuses = options.retryStatuses ?? DEFAULT_RETRY_STATUSES;
|
|
return allowedMethods.includes(method) && retryStatuses.includes(response.status);
|
|
}
|
|
|
|
function shouldRetryError(method: string, error: unknown, options: FetchRetryOptions) {
|
|
const allowedMethods = options.retryMethods ?? ["GET", "HEAD"];
|
|
if (!allowedMethods.includes(method)) {
|
|
return false;
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return error.name === "AbortError" || error.name === "TimeoutError" || error.message.includes("fetch");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export async function fetchWithTimeout(input: string | URL, init: RequestInit | undefined, options: FetchRetryOptions) {
|
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
const retries = Math.max(0, options.retries ?? 0);
|
|
const baseDelayMs = Math.max(150, options.baseDelayMs ?? 350);
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
const controller = new AbortController();
|
|
const upstreamSignal = init?.signal;
|
|
let didTimeout = false;
|
|
|
|
const timeout = setTimeout(() => {
|
|
didTimeout = true;
|
|
controller.abort();
|
|
}, options.timeoutMs);
|
|
|
|
const abortHandler = () => controller.abort();
|
|
upstreamSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
|
|
try {
|
|
const response = await fetch(input, {
|
|
...init,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (attempt < retries && shouldRetryResponse(method, response, options)) {
|
|
await response.text().catch(() => undefined);
|
|
await sleep(baseDelayMs * (attempt + 1));
|
|
continue;
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
if (didTimeout) {
|
|
lastError = new Error(`Request timed out after ${options.timeoutMs}ms`);
|
|
} else {
|
|
lastError = error;
|
|
}
|
|
|
|
if (attempt >= retries || !shouldRetryError(method, lastError, options)) {
|
|
throw lastError instanceof Error ? lastError : new Error("Request failed");
|
|
}
|
|
|
|
await sleep(baseDelayMs * (attempt + 1));
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
upstreamSignal?.removeEventListener("abort", abortHandler);
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error("Request failed");
|
|
}
|