/**
* 带超时、重试和指数退避的 fetch 请求工具
*
* @param {string | URL} url
* @param {RequestInit & {
* retries?: number;
* retryDelay?: number;
* timeout?: number;
* retryOn?: (response?: Response, error?: unknown) => boolean;
* }} options
* @returns {Promise<Response>}
*/
async function requestWithRetry(url, options = {}) {
const {
retries = 3,
retryDelay = 300,
timeout = 5000,
retryOn = (response, error) => {
// 网络错误、超时,以及 408、429 和 5xx 通常适合重试
return Boolean(
error ||
response?.status === 408 ||
response?.status === 429 ||
response?.status >= 500
);
},
signal: externalSignal,
...fetchOptions
} = options;
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// 外部 signal 取消时,同时取消当前请求
const abortRequest = () => controller.abort();
externalSignal?.addEventListener("abort", abortRequest, { once: true });
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal
});
if (!retryOn(response)) {
return response;
}
lastError = new Error(`Request failed with status ${response.status}`);
lastError.response = response;
} catch (error) {
lastError = error;
// 主动取消不应该继续重试
if (externalSignal?.aborted) {
throw error;
}
if (!retryOn(undefined, error)) {
throw error;
}
} finally {
clearTimeout(timeoutId);
externalSignal?.removeEventListener("abort", abortRequest);
}
if (attempt === retries) break;
// 指数退避 + 随机抖动,避免大量请求同时重试
const exponentialDelay = retryDelay * 2 ** attempt;
const jitter = Math.random() * retryDelay;
await new Promise((resolve) => {
setTimeout(resolve, exponentialDelay + jitter);
});
}
throw lastError;
}