开篇:网络请求的脆弱性与健壮性设计的必要性
在构建现代Web应用时,我们常常面临一个无法回避的现实:网络环境是复杂且不可靠的。无论是客户端与服务器之间的物理距离、中间网络设备的瞬时故障、DNS解析问题,还是服务器自身的负载过高、短暂的服务中断,都可能导致网络请求失败。对于用户而言,这意味着页面加载缓慢、数据无法刷新、操作无法完成,从而严重损害用户体验。
想象一个电商应用,用户点击购买商品,但由于网络瞬时抖动,请求未能及时到达服务器。如果系统只是简单地显示一个错误信息,用户可能会感到沮丧,甚至放弃购买。反之,如果系统能够智能地进行几次重试,并在合理的时间内等待响应,那么用户可能根本不会察觉到背后的网络问题,从而获得流畅无阻的体验。
因此,设计健壮的网络请求机制,使其能够优雅地处理超时、瞬时错误和网络波动,是任何高可用、用户友好型应用不可或缺的一环。本文旨在深入探讨如何利用JavaScript的Promise机制,封装一个具备超时与重试能力的网络请求函数,从而显著提升应用的健壮性。我们将从Promise的基础回顾开始,逐步构建一个功能完善、配置灵活的解决方案,并探讨其在实际应用中的最佳实践。
Promise基础回顾与核心概念奠定
在深入实现之前,我们首先回顾一下JavaScript中Promise及其相关概念,它们是构建健壮请求机制的基石。
Promise状态与链式调用
Promise代表一个异步操作的最终完成(或失败)及其结果值。一个Promise有三种状态:
- Pending (待定):初始状态,既没有成功,也没有失败。
- Fulfilled (已成功):操作成功完成。
- Rejected (已失败):操作失败。
Promise的状态一旦从Pending变为Fulfilled或Rejected,就不可逆转。我们可以使用.then()方法处理Promise的成功结果,使用.catch()方法处理失败结果,并使用.finally()方法无论成功或失败都会执行。
new Promise((resolve, reject) => {
// 异步操作
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve("数据加载成功!");
} else {
reject(new Error("网络请求失败!"));
}
}, 1000);
})
.then(data => {
console.log("成功:", data);
})
.catch(error => {
console.error("失败:", error.message);
})
.finally(() => {
console.log("操作完成,无论成功或失败。");
});
async/await的语法糖
async/await是基于Promise的语法糖,它使得异步代码的编写和阅读更像同步代码,极大地提高了可读性。async函数总是返回一个Promise,而await关键字只能在async函数中使用,它会暂停async函数的执行,直到其后的Promise解决(fulfilled或rejected)。
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log("数据:", data);
} catch (error) {
console.error("请求出错:", error.message);
} finally {
console.log("fetchData函数执行完毕。");
}
}
fetchData();
async/await在处理重试逻辑时尤为方便,因为它允许我们使用传统的for循环或while循环结构来管理多次尝试。
超时机制的基石:Promise.race
Promise.race()方法接收一个Promise数组作为输入,并返回一个新的Promise。这个新的Promise在数组中的任何一个Promise解决(fulfilled或rejected)时,就会立即解决,其结果值或拒绝原因与最先解决的那个Promise相同。
这个特性使得Promise.race()成为实现超时机制的理想工具。我们可以将实际的网络请求Promise与一个在指定时间后拒绝的Promise(即超时Promise)传入Promise.race()。如果网络请求在超时Promise拒绝之前完成,那么Promise.race()将返回网络请求的结果;否则,它将返回超时Promise的拒绝结果。
function createTimeoutPromise(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error(`请求超时,超过 ${ms}ms 未响应`));
}, ms);
});
}
// 示例用法
const requestPromise = fetch('https://api.example.com/slow-data');
const timeoutPromise = createTimeoutPromise(2000); // 2秒超时
Promise.race([requestPromise, timeoutPromise])
.then(response => response.json())
.then(data => console.log("数据:", data))
.catch(error => console.error("发生错误:", error.message));
重试机制的基石:循环与递归
重试机制的核心在于当一个操作失败时,尝试再次执行它。这可以通过两种主要方式实现:
- 循环 (Loop):使用
for或while循环来迭代执行操作,直到成功或达到最大重试次数。async/await的出现使得在循环中使用异步操作变得非常直观。 - 递归 (Recursion):定义一个函数,在操作失败时,在函数内部再次调用自身。这通常需要一个计数器来限制递归深度,防止无限循环。
考虑到async/await的普及和更清晰的控制流,我们倾向于在循环中实现重试逻辑。
超时机制的精细实现
超时是网络请求健壮性中的第一道防线。一个请求长时间没有响应,不仅浪费资源,还会阻塞用户界面,造成不好的用户体验。
单一Promise的超时封装
我们将构建一个withTimeout函数,它接收一个Promise和一个超时时间,返回一个新的Promise。
/**
* 定义一个自定义错误类型,用于表示请求超时
*/
class TimeoutError extends Error {
constructor(message = "请求超时") {
super(message);
this.name = 'TimeoutError';
// 保持堆栈跟踪的正确性
if (Error.captureStackTrace) {
Error.captureStackTrace(this, TimeoutError);
}
}
}
/**
* 为给定的Promise添加超时机制
* @param {Promise<T>} promise 要添加超时的原始Promise
* @param {number} ms 超时时间(毫秒)
* @param {string} [timeoutMessage] 超时时使用的错误信息
* @returns {Promise<T>} 带有超时功能的新Promise
*/
function withTimeout(promise, ms, timeoutMessage = `请求超时,超过 ${ms}ms 未响应`) {
// 创建一个在指定时间后拒绝的Promise
const timeout = new Promise((resolve, reject) => {
const id = setTimeout(() => {
clearTimeout(id); // 清除定时器,避免内存泄露
reject(new TimeoutError(timeoutMessage));
}, ms);
});
// 使用Promise.race来竞争原始Promise和超时Promise
return Promise.race([promise, timeout]);
}
代码解析:
- 我们首先定义了一个
TimeoutError类,这是一个自定义错误类型,有助于我们区分不同类型的错误。在处理错误时,我们可以通过error instanceof TimeoutError来判断是否是超时错误。 withTimeout函数的核心是Promise.race([promise, timeout])。timeoutPromise内部使用setTimeout来模拟超时。当setTimeout回调执行时,它会reject一个TimeoutError。clearTimeout(id)在setTimeout回调内部被调用,确保即使原始Promise先完成,这个定时器也会被清理,避免潜在的内存泄漏或不必要的执行。
示例用法:
async function simulateNetworkRequest(duration, shouldFail = false) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error("模拟请求失败"));
} else {
resolve(`数据在 ${duration}ms 后成功返回`);
}
}, duration);
});
}
(async () => {
console.log("--- 测试 withTimeout ---");
// 场景1: 请求成功,未超时
try {
const result = await withTimeout(simulateNetworkRequest(500), 1000);
console.log("场景1成功:", result); // 输出: 数据在 500ms 后成功返回
} catch (error) {
console.error("场景1失败:", error.message);
}
// 场景2: 请求超时
try {
const result = await withTimeout(simulateNetworkRequest(1500), 1000);
console.log("场景2成功:", result);
} catch (error) {
console.error("场景2失败:", error.message); // 输出: 请求超时,超过 1000ms 未响应
}
// 场景3: 请求失败,未超时
try {
const result = await withTimeout(simulateNetworkRequest(300, true), 1000);
console.log("场景3成功:", result);
} catch (error) {
console.error("场景3失败:", error.message); // 输出: 模拟请求失败
}
})();
AbortController在超时中的应用:真正取消请求
虽然Promise.race可以处理超时,但它并不能真正地“取消”底层正在进行的网络请求。当超时发生时,原始的网络请求可能仍在后台运行,即使其结果被忽略。对于长时间运行或资源消耗大的请求,这可能会浪费带宽和服务器资源。
AbortController是一个Web API,它提供了一种信号机制来取消一个或多个Web请求。fetch API支持通过signal选项接收AbortSignal对象。
/**
* 为给定的Promise(假设其支持AbortSignal)添加超时机制,并尝试取消底层操作
* @param {function(AbortSignal): Promise<T>} requestFn 一个接受AbortSignal并返回Promise的函数
* @param {number} ms 超时时间(毫秒)
* @param {string} [timeoutMessage] 超时时使用的错误信息
* @returns {Promise<T>} 带有超时功能的新Promise
*/
function withTimeoutAndAbort(requestFn, ms, timeoutMessage = `请求超时,超过 ${ms}ms 未响应`) {
const controller = new AbortController();
const signal = controller.signal;
const requestPromise = requestFn(signal);
const timeoutPromise = new Promise((resolve, reject) => {
const id = setTimeout(() => {
clearTimeout(id);
controller.abort(); // 超时时发送取消信号
reject(new TimeoutError(timeoutMessage));
}, ms);
});
return Promise.race([requestPromise, timeoutPromise])
.finally(() => {
// 无论请求成功、失败还是超时,确保定时器被清理
// 注意:这里无法直接清除timeoutPromise内部的setTimeout,
// 但如果requestPromise先完成,timeoutPromise的reject不会被race返回
// 且controller.abort()只在timeoutPromise被选中时执行。
// 更好的做法是将setTimeout的id暴露出来,或者在race的then/catch中清除。
// 实际上,Promise.race的特性决定了当其中一个Promise解决后,另一个Promise的settle状态不会影响race的结果。
// 但为了资源清理,我们可以在外部处理定时器ID。
});
}
// 改进 withTimeoutAndAbort 的定时器清理
function withTimeoutAndAbortImproved(requestFn, ms, timeoutMessage = `请求超时,超过 ${ms}ms 未响应`) {
const controller = new AbortController();
const signal = controller.signal;
const requestPromise = requestFn(signal);
let timeoutId;
const timeoutPromise = new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
controller.abort(); // 超时时发送取消信号
reject(new TimeoutError(timeoutMessage));
}, ms);
});
return Promise.race([requestPromise, timeoutPromise])
.finally(() => {
// 无论哪个Promise首先解决,都清理定时器
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
// 模拟一个支持 AbortSignal 的网络请求函数
async function mockFetch(url, options) {
const signal = options?.signal;
const duration = Math.random() * 2000 + 500; // 500ms - 2500ms 随机延迟
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (signal?.aborted) {
reject(new DOMException("请求被中止", "AbortError"));
} else {
resolve({
status: 200,
data: `数据来自 ${url},耗时 ${duration.toFixed(0)}ms`
});
}
}, duration);
signal?.addEventListener('abort', () => {
clearTimeout(timeout); // 如果请求被中止,清除模拟定时器
reject(new DOMException("请求被中止", "AbortError"));
}, { once: true });
});
}
(async () => {
console.log("n--- 测试 withTimeoutAndAbortImproved ---");
// 场景1: 请求成功,未超时
try {
const result = await withTimeoutAndAbortImproved(
(signal) => mockFetch('https://api.example.com/fast', { signal }),
1000 // 1秒超时
);
console.log("场景1成功:", result.data);
} catch (error) {
console.error("场景1失败:", error.name, error.message);
}
// 场景2: 请求超时,并尝试中止底层请求
try {
const result = await withTimeoutAndAbortImproved(
(signal) => mockFetch('https://api.example.com/slow', { signal }),
500 // 0.5秒超时
);
console.log("场景2成功:", result.data);
} catch (error) {
console.error("场景2失败:", error.name, error.message); // 输出: TimeoutError 请求超时...
}
})();
代码解析:
withTimeoutAndAbortImproved函数现在接收一个函数requestFn,这个函数会接收一个AbortSignal并返回一个Promise。这是因为AbortSignal需要被传递到实际的网络请求中(例如fetch(url, { signal }))。- 当超时发生时,
controller.abort()会被调用,这将触发所有注册到signal上的abort事件监听器。 - 在
mockFetch模拟函数中,我们监听了signal.aborted事件,并在事件触发时清除内部定时器并拒绝Promise,模拟了真正的请求取消行为。 finally块用于清理setTimeout,确保即使原始请求先完成,超时定时器也能被清除。
AbortController是实现真正意义上取消请求的关键,特别适用于fetch API。对于像axios这样的库,它们通常也提供了类似的取消机制(例如通过CancelToken或直接支持AbortController)。
重试机制的艺术与策略
当网络请求因为瞬时故障而失败时,简单的重试往往能解决问题。然而,盲目的重试可能适得其反,加剧服务器负担,甚至导致分布式拒绝服务(DDoS)攻击。因此,我们需要设计智能的重试策略。
基本重试逻辑:简单循环与计数
最简单的重试逻辑是在一个循环中不断尝试,直到成功或达到最大重试次数。
/**
* 为给定的异步操作添加重试机制
* @param {function(): Promise<T>} asyncOperation 一个返回Promise的异步操作函数
* @param {number} maxRetries 最大重试次数
* @returns {Promise<T>} 经过重试处理的新Promise
*/
async function withRetry(asyncOperation, maxRetries) {
let attempts = 0;
while (attempts <= maxRetries) {
try {
return await asyncOperation();
} catch (error) {
attempts++;
if (attempts > maxRetries) {
console.error(`尝试失败 ${attempts}/${maxRetries} 次,不再重试。`);
throw error; // 达到最大重试次数,抛出最终错误
}
console.warn(`尝试失败 ${attempts}/${maxRetries} 次,正在重试...`);
// 这里可以添加延迟
}
}
}
// 模拟一个有时会失败的异步操作
let failCount = 0;
async function unreliableOperation() {
failCount++;
console.log(`执行 unreliableOperation,当前是第 ${failCount} 次尝试`);
if (failCount < 3) { // 前两次失败
throw new Error(`模拟瞬时错误 ${failCount}`);
}
return `操作成功在第 ${failCount} 次尝试`;
}
(async () => {
console.log("n--- 测试 withRetry ---");
try {
failCount = 0; // 重置计数器
const result = await withRetry(unreliableOperation, 2); // 允许重试2次,总共3次尝试
console.log("最终结果:", result); // 预期:操作成功在第 3 次尝试
} catch (error) {
console.error("最终失败:", error.message);
}
try {
failCount = 0; // 重置计数器
const result = await withRetry(unreliableOperation, 1); // 允许重试1次,总共2次尝试
console.log("最终结果:", result);
} catch (error) {
console.error("最终失败:", error.message); // 预期:最终失败: 模拟瞬时错误 2
}
})();
代码解析:
withRetry函数使用while循环来控制重试次数。try...catch块用于捕获asyncOperation可能抛出的错误。attempts计数器跟踪当前尝试次数。- 当
attempts超过maxRetries时,说明所有重试都已用尽,此时抛出最后的错误。
重试间隔策略
仅仅重试是不够的,我们需要在重试之间引入延迟,以避免立即重试再次失败,同时给服务器一个恢复的时间。
1. 固定延迟 (Fixed Delay)
每次重试都等待相同的时间间隔。简单易实现,适用于对延迟要求不高的场景。
/**
* 延迟指定毫秒数
* @param {number} ms 延迟时间(毫秒)
* @returns {Promise<void>}
*/
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 在 withRetry 中加入固定延迟
async function withRetryFixedDelay(asyncOperation, maxRetries, retryDelayMs) {
let attempts = 0;
while (attempts <= maxRetries) {
try {
return await asyncOperation();
} catch (error) {
attempts++;
if (attempts > maxRetries) {
console.error(`尝试失败 ${attempts}/${maxRetries} 次,不再重试。`);
throw error;
}
console.warn(`尝试失败 ${attempts}/${maxRetries} 次,等待 ${retryDelayMs}ms 后重试...`);
await delay(retryDelayMs); // 固定延迟
}
}
}
(async () => {
console.log("n--- 测试 withRetryFixedDelay ---");
let failCountFixed = 0;
async function unreliableFixed() {
failCountFixed++;
console.log(`执行 unreliableFixed,当前是第 ${failCountFixed} 次尝试`);
if (failCountFixed < 3) {
throw new Error(`模拟瞬时错误 ${failCountFixed}`);
}
return `操作成功在第 ${failCountFixed} 次尝试`;
}
try {
failCountFixed = 0;
const result = await withRetryFixedDelay(unreliableFixed, 2, 500); // 每次重试等待500ms
console.log("最终结果:", result);
} catch (error) {
console.error("最终失败:", error.message);
}
})();
2. 指数退避 (Exponential Backoff)
每次重试的延迟时间会随着重试次数的增加而指数级增长。例如,第一次重试延迟 baseDelay,第二次 baseDelay * factor,第三次 baseDelay * factor^2,以此类推。这有助于在系统负载较高时,客户端逐渐减少请求频率,给服务器更多恢复时间。
// 在 withRetry 中加入指数退避
async function withRetryExponentialBackoff(asyncOperation, maxRetries, baseDelayMs, factor = 2) {
let attempts = 0;
while (attempts <= maxRetries) {
try {
return await asyncOperation();
} catch (error) {
attempts++;
if (attempts > maxRetries) {
console.error(`尝试失败 ${attempts}/${maxRetries} 次,不再重试。`);
throw error;
}
const currentDelay = baseDelayMs * Math.pow(factor, attempts - 1); // 第一次重试(attempts=1)延迟 baseDelayMs
console.warn(`尝试失败 ${attempts}/${maxRetries} 次,等待 ${currentDelay.toFixed(0)}ms 后重试...`);
await delay(currentDelay);
}
}
}
(async () => {
console.log("n--- 测试 withRetryExponentialBackoff ---");
let failCountExp = 0;
async function unreliableExp() {
failCountExp++;
console.log(`执行 unreliableExp,当前是第 ${failCountExp} 次尝试`);
if (failCountExp < 4) { // 前三次失败
throw new Error(`模拟瞬时错误 ${failCountExp}`);
}
return `操作成功在第 ${failCountExp} 次尝试`;
}
try {
failCountExp = 0;
const result = await withRetryExponentialBackoff(unreliableExp, 3, 100, 2); // 100ms, 200ms, 400ms 延迟
console.log("最终结果:", result);
} catch (error) {
console.error("最终失败:", error.message);
}
})();
3. 带抖动指数退避 (Exponential Backoff with Jitter)
纯粹的指数退避虽然有效,但如果大量客户端在同一时间由于相同的错误而开始重试,它们的重试时间点可能会同步,形成“惊群效应”(Thundering Herd),导致服务器再次被压垮。
为了避免这种情况,我们可以在指数退避的基础上添加随机抖动(Jitter),使得每个客户端的重试延迟略有不同,从而分散请求。常见的抖动策略有两种:
- Full Jitter: 在
[0, currentDelay]之间随机选择一个延迟。 - Decorrelated Jitter: 每次延迟随机选择一个值,但最大值随着重试次数增加。例如,
random(0, min(maxDelay, baseDelay * factor^attempts))。
这里我们实现一个简单的Full Jitter:
// 在 withRetry 中加入带抖动指数退避
async function withRetryExponentialBackoffWithJitter(asyncOperation, maxRetries, baseDelayMs, factor = 2, maxJitterMs = 0) {
let attempts = 0;
while (attempts <= maxRetries) {
try {
return await asyncOperation();
} catch (error) {
attempts++;
if (attempts > maxRetries) {
console.error(`尝试失败 ${attempts}/${maxRetries} 次,不再重试。`);
throw error;
}
let currentDelay = baseDelayMs * Math.pow(factor, attempts - 1);
if (maxJitterMs > 0) {
// 将抖动限制在 maxJitterMs 范围内,并确保不超出当前延迟的合理范围
const jitter = Math.random() * Math.min(currentDelay, maxJitterMs);
currentDelay = Math.floor(jitter); // Full Jitter: 延迟在 [0, currentDelay] 之间随机
}
console.warn(`尝试失败 ${attempts}/${maxRetries} 次,等待 ${currentDelay.toFixed(0)}ms 后重试 (带抖动)...`);
await delay(currentDelay);
}
}
}
(async () => {
console.log("n--- 测试 withRetryExponentialBackoffWithJitter ---");
let failCountJitter = 0;
async function unreliableJitter() {
failCountJitter++;
console.log(`执行 unreliableJitter,当前是第 ${failCountJitter} 次尝试`);
if (failCountJitter < 4) {
throw new Error(`模拟瞬时错误 ${failCountJitter}`);
}
return `操作成功在第 ${failCountJitter} 次尝试`;
}
try {
failCountJitter = 0;
const result = await withRetryExponentialBackoffWithJitter(unreliableJitter, 3, 100, 2, 200); // 100ms, 200ms, 400ms 基础延迟,最大抖动200ms
console.log("最终结果:", result);
} catch (error) {
console.error("最终失败:", error.message);
}
})();
可重试错误判断:retryOnPredicate
并非所有错误都应该触发重试。例如,4xx 客户端错误(如 400 Bad Request, 401 Unauthorized, 404 Not Found)通常表示请求本身有问题,重试也无济于事,只会浪费资源。而 5xx 服务器错误(如 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable)或网络错误(如TypeError: Network request failed)则可能是瞬时故障,值得重试。
我们可以引入一个谓词函数retryOnPredicate,它接收错误对象作为参数,并返回一个布尔值,指示是否应该进行重试。
/**
* 判断一个错误是否是可重试的
* @param {Error | object} error 错误对象
* @returns {boolean} 如果错误是可重试的,则返回 true
*/
function defaultRetryOnPredicate(error) {
// 假设常见的网络错误或服务器错误是可重试的
if (error instanceof TimeoutError) {
return true; // 超时通常是可重试的
}
if (error instanceof DOMException && error.name === "AbortError") {
return false; // 主动中止不应该重试
}
// 检查 fetch API 产生的网络错误 (TypeError)
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return true;
}
// 检查 HTTP 状态码 (需要从响应中获取)
// 这里假设error对象可能包含response信息,或者我们可以在asyncOperation中捕获并封装
if (error && error.response && typeof error.response.status === 'number') {
const status = error.response.status;
return status >= 500 && status <= 599; // 5xx 服务器错误
}
// 其他未知的错误也可能值得重试,具体取决于业务逻辑
return true; // 默认情况下,对未知错误进行重试
}
// 修改 withRetryExponentialBackoffWithJitter 结构以接受 retryOnPredicate
async function withRetryAdvanced(
asyncOperation,
maxRetries,
baseDelayMs,
factor,
maxJitterMs,
retryOnPredicate = defaultRetryOnPredicate
) {
let attempts = 0;
while (attempts <= maxRetries) {
try {
return await asyncOperation();
} catch (error) {
// 在这里判断是否应该重试
if (!retryOnPredicate(error)) {
console.error(`遇到不可重试错误,立即停止重试:`, error);
throw error;
}
attempts++;
if (attempts > maxRetries) {
console.error(`尝试失败 ${attempts}/${maxRetries} 次,达到最大重试次数,不再重试。`);
throw error;
}
let currentDelay = baseDelayMs * Math.pow(factor, attempts - 1);
if (maxJitterMs > 0) {
const jitter = Math.random() * Math.min(currentDelay, maxJitterMs);
currentDelay = Math.floor(jitter);
}
console.warn(`尝试失败 ${attempts}/${maxRetries} 次,等待 ${currentDelay.toFixed(0)}ms 后重试 (带抖动)...`);
await delay(currentDelay);
}
}
}
// 模拟一个有时会失败,有时会返回不可重试错误的异步操作
let failCountAdvanced = 0;
async function unreliableAdvanced() {
failCountAdvanced++;
console.log(`执行 unreliableAdvanced,当前是第 ${failCountAdvanced} 次尝试`);
if (failCountAdvanced === 1) {
// 模拟瞬时网络错误,可重试
throw new TypeError('Failed to fetch');
} else if (failCountAdvanced === 2) {
// 模拟服务器错误,可重试
const err = new Error('Internal Server Error');
err.response = { status: 500 }; // 附加响应信息
throw err;
} else if (failCountAdvanced === 3) {
// 模拟客户端错误,不可重试
const err = new Error('Bad Request');
err.response = { status: 400 }; // 附加响应信息
throw err;
}
return `操作成功在第 ${failCountAdvanced} 次尝试`;
}
(async () => {
console.log("n--- 测试 withRetryAdvanced ---");
try {
failCountAdvanced = 0;
const result = await withRetryAdvanced(unreliableAdvanced, 3, 100, 2, 200);
console.log("最终结果:", result);
} catch (error) {
console.error("最终失败:", error.message); // 预期:最终失败: Bad Request (因为400不可重试)
}
})();
代码解析:
defaultRetryOnPredicate函数提供了一个默认的错误判断逻辑。它考虑了TimeoutError、DOMException("AbortError")(表示请求被主动取消)、TypeError: Failed to fetch(常见的网络中断错误),以及HTTP 5xx 状态码。withRetryAdvanced在catch块的开头调用retryOnPredicate。如果函数返回false,则立即停止重试并抛出错误。- 这使得重试策略更加智能和高效,避免了对不可恢复错误的无谓尝试。
超时与重试机制的融合:构建健壮的网络请求封装
现在,我们将超时和重试这两种机制结合起来,构建一个功能强大的robustFetch函数,用于封装fetch API。
设计robustFetch函数签名与配置项
一个健壮的封装函数需要灵活的配置选项。我们将设计robustFetch函数接收原始的fetch请求参数和一个配置对象。
/**
* 健壮的 fetch 封装函数配置选项
* @typedef {object} RobustFetchOptions
* @property {number} [maxRetries=3] 最大重试次数(不包括第一次尝试)
* @property {number} [retryDelay=100] 第一次重试的延迟时间(毫秒),用于指数退避的基数
* @property {number} [retryFactor=2] 指数退避的因子
* @property {number} [retryJitter=0] 抖动范围,最大抖动毫秒数。0表示无抖动
* @property {number} [timeout=10000] 单次请求的超时时间(毫秒)
* @property {function(Error): boolean} [retryOnPredicate] 判断是否应该重试的函数,默认为 defaultRetryOnPredicate
* @property {function(number, Error): void} [onRetryAttempt] 每次重试前调用的回调函数 (attempts, error)
* @property {number} [totalTimeout] 整个重试过程的总超时时间(毫秒),默认为无穷大
*/
/**
* 健壮的 fetch 封装函数
* @param {RequestInfo | URL} input 与 fetch API 相同的第一个参数
* @param {RequestInit & RobustFetchOptions} [options] 与 fetch API 相同的第二个参数,并扩展了 RobustFetchOptions
* @returns {Promise<Response>}
*/
async function robustFetch(input, options = {}) {
// ... 实现细节
}
配置项说明表格:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
maxRetries |
number |
3 |
最大重试次数。总尝试次数为 1 + maxRetries。 |
retryDelay |
number |
100 |
第一次重试的延迟基数(毫秒)。用于指数退避的起始值。 |
retryFactor |
number |
2 |
指数退避的因子。每次重试延迟时间会乘以这个因子。 |
retryJitter |
number |
0 |
最大抖动范围(毫秒)。在计算延迟后,会在此范围内添加随机值,以避免惊群效应。0 表示无抖动。 |
timeout |
number |
10000 |
单次请求的超时时间(毫秒)。每次重试尝试都会应用这个超时。 |
retryOnPredicate |
function |
defaultRetryOnPredicate |
一个函数,接收错误对象,返回 true 表示可重试,false 表示不可重试。 |
onRetryAttempt |
function |
undefined |
每次重试前(即第一次失败后)调用的回调函数。参数为 (attemptNumber, error)。 |
totalTimeout |
number |
undefined |
整个重试过程的总超时时间(毫秒)。如果整个过程超出此时间,将抛出错误。默认为无总超时。 |
单次尝试的超时与整体请求的超时
这里有一个重要的设计决策:
- 单次尝试的超时 (
timeout): 每次重试都应该有自己的超时时间,防止某次尝试卡死。 - 整体请求的超时 (
totalTimeout): 整个重试过程,从第一次尝试到最后一次尝试,都应该有一个总体的最大时间限制。这可以防止在极端情况下,即使每次重试都很快,但由于重试次数过多,导致整个操作耗时过长。
核心逻辑的迭代构建
我们将分步构建robustFetch:
1. 辅助函数 delay 和 TimeoutError
这些已经在前面定义过,这里直接使用。
// 已在前面定义
// class TimeoutError extends Error { ... }
// function delay(ms) { ... }
2. defaultRetryOnPredicate
同样,已在前面定义。
// 已在前面定义
// function defaultRetryOnPredicate(error) { ... }
3. robustFetch主体结构
async function robustFetch(input, options = {}) {
const {
maxRetries = 3,
retryDelay = 100,
retryFactor = 2,
retryJitter = 0,
timeout = 10000, // 单次请求超时
retryOnPredicate = defaultRetryOnPredicate,
onRetryAttempt,
totalTimeout, // 整体超时
...fetchOptions // 传递给原生 fetch 的选项
} = options;
let attempts = 0;
let totalTimeoutId;
// 如果设置了 totalTimeout,则启动一个整体的超时计时器
if (totalTimeout !== undefined) {
totalTimeoutId = setTimeout(() => {
// 抛出错误,但这里需要确保这个错误能被外层捕获并终止整个循环
// 更好的做法是使用 AbortController 来取消所有后续尝试
// 但为了简化,这里先抛出错误,并假定它会中断循环
// 实际应用中,可以通过一个外部 AbortController 来实现
throw new TimeoutError(`整个请求过程超时,超过 ${totalTimeout}ms 未完成`);
}, totalTimeout);
}
try {
while (attempts <= maxRetries) {
attempts++;
const controller = new AbortController();
const signal = controller.signal;
let currentAttemptTimeoutId;
try {
// 设置单次请求超时
currentAttemptTimeoutId = setTimeout(() => {
controller.abort(); // 超时时取消本次请求
}, timeout);
// 将 abort signal 传递给 fetch
const response = await fetch(input, { ...fetchOptions, signal });
// 检查 HTTP 状态码,如果是非 2xx 范围,也视为错误(需要自定义处理)
if (!response.ok) {
// 如果响应不是成功的 (例如 4xx, 5xx), 抛出错误
// 这样 retryOnPredicate 就可以根据 response.status 判断
const error = new Error(`HTTP 错误: ${response.status} ${response.statusText}`);
// 可以在 error 对象上附加 response 属性,供 retryOnPredicate 使用
error.response = response;
throw error;
}
return response; // 请求成功,返回响应
} catch (error) {
// 清除当前尝试的超时定时器
if (currentAttemptTimeoutId) {
clearTimeout(currentAttemptTimeoutId);
}
// 如果是 AbortError 且不是 TimeoutError 引起的,可能是用户主动取消或 totalTimeout 引起的
if (error instanceof DOMException && error.name === 'AbortError' && !(error instanceof TimeoutError)) {
// 检查是否是 totalTimeout 引起的 AbortError
// 这一步复杂,因为 AbortController 无法区分是谁发出的 abort 信号
// 假设这里 AbortError 意味着请求被成功取消,不再重试
throw error; // 可能是 totalTimeout 导致,或者用户主动取消
}
// 判断是否应该重试
if (attempts <= maxRetries && retryOnPredicate(error)) {
console.warn(`第 ${attempts}/${maxRetries + 1} 次尝试失败,错误: ${error.message || error.name}。准备重试...`);
if (onRetryAttempt) {
onRetryAttempt(attempts, error);
}
// 计算延迟
let currentDelay = retryDelay * Math.pow(retryFactor, attempts - 1);
if (retryJitter > 0) {
const jitter = Math.random() * Math.min(currentDelay, retryJitter);
currentDelay = Math.floor(jitter);
}
await delay(currentDelay);
} else {
// 不再重试,抛出最终错误
console.error(`所有尝试失败或遇到不可重试错误。最终错误:`, error);
throw error;
}
}
}
} finally {
// 无论成功还是失败,都要清除总超时定时器
if (totalTimeoutId) {
clearTimeout(totalTimeoutId);
}
}
// 理论上不会执行到这里,因为循环要么返回,要么抛出错误
throw new Error("RobustFetch 意外结束,未返回结果或抛出错误。");
}
上述实现存在一个问题:totalTimeout的实现方式。
直接在外部setTimeout中抛出错误,并不能优雅地中断while循环中的await。一个更健壮的方法是,将totalTimeout也通过一个AbortController来管理,并将其signal传递给每次fetch请求。
改进 totalTimeout 与 AbortController 机制
为了更好地处理totalTimeout和取消,我们将使用一个顶层的AbortController来管理整个robustFetch过程。
/**
* 定义一个自定义错误类型,用于表示请求超时
*/
class TimeoutError extends Error {
constructor(message = "请求超时") {
super(message);
this.name = 'TimeoutError';
if (Error.captureStackTrace) {
Error.captureStackTrace(this, TimeoutError);
}
}
}
/**
* 延迟指定毫秒数
* @param {number} ms 延迟时间(毫秒)
* @param {AbortSignal} [signal] 可选的 AbortSignal,用于取消延迟
* @returns {Promise<void>}
*/
function delay(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
return reject(new DOMException("Delay aborted", "AbortError"));
}
const timeoutId = setTimeout(() => {
resolve();
}, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(new DOMException("Delay aborted", "AbortError"));
}, { once: true });
});
}
/**
* 判断一个错误是否是可重试的
* @param {Error | object} error 错误对象
* @returns {boolean} 如果错误是可重试的,则返回 true
*/
function defaultRetryOnPredicate(error) {
if (error instanceof TimeoutError) {
return true;
}
if (error instanceof DOMException && error.name === "AbortError") {
// 如果是 AbortError,只有当它是由超时引起的(即TimeoutError的实例)才重试
// 否则,通常是外部取消,不应重试
return error instanceof TimeoutError;
}
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return true;
}
// 假设 error.response 存在且有 status
if (error && error.response && typeof error.response.status === 'number') {
const status = error.response.status;
return status >= 500 && status <= 599; // 5xx 服务器错误
}
return true; // 默认对未知错误进行重试
}
/**
* 健壮的 fetch 封装函数配置选项
* @typedef {object} RobustFetchOptions
* @property {number} [maxRetries=3] 最大重试次数(不包括第一次尝试)
* @property {number} [retryDelay=100] 第一次重试的延迟时间(毫秒),用于指数退避的基数
* @property {number} [retryFactor=2] 指数退避的因子
* @property {number} [retryJitter=0] 抖动范围,最大抖动毫秒数。0表示无抖动
* @property {number} [timeout=10000] 单次请求的超时时间(毫秒)
* @property {function(Error): boolean} [retryOnPredicate] 判断是否应该重试的函数,默认为 defaultRetryOnPredicate
* @property {function(number, Error): void} [onRetryAttempt] 每次重试前调用的回调函数 (attempts, error)
* @property {number} [totalTimeout] 整个重试过程的总超时时间(毫秒),默认为无穷大
* @property {AbortSignal} [signal] 外部传入的 AbortSignal,用于取消整个 robustFetch 过程
*/
/**
* 健壮的 fetch 封装函数
* @param {RequestInfo | URL} input 与 fetch API 相同的第一个参数
* @param {RequestInit & RobustFetchOptions} [options] 与 fetch API 相同的第二个参数,并扩展了 RobustFetchOptions
* @returns {Promise<Response>}
*/
async function robustFetch(input, options = {}) {
const {
maxRetries = 3,
retryDelay = 100,
retryFactor = 2,
retryJitter = 0,
timeout = 10000,
retryOnPredicate = defaultRetryOnPredicate,
onRetryAttempt,
totalTimeout,
signal: externalSignal, // 外部传入的 AbortSignal
...fetchOptions
} = options;
let attempts = 0;
let totalTimeoutController;
let totalTimeoutSignal;
// 如果设置了 totalTimeout 或有外部信号,则创建顶层 AbortController
if (totalTimeout !== undefined || externalSignal) {
totalTimeoutController = new AbortController();
totalTimeoutSignal = totalTimeoutController.signal;
// 如果有外部信号,将其事件监听器连接到我们的总信号
if (externalSignal) {
externalSignal.addEventListener('abort', () => totalTimeoutController.abort(), { once: true });
}
// 如果设置了 totalTimeout,启动定时器
if (totalTimeout !== undefined) {
setTimeout(() => {
// 如果 totalTimeoutSignal 已经被外部信号中止,则不再次中止
if (!totalTimeoutSignal.aborted) {
totalTimeoutController.abort(new TimeoutError(`整个请求过程超时,超过 ${totalTimeout}ms 未完成`));
}
}, totalTimeout);
}
}
try {
while (attempts <= maxRetries) {
// 检查总信号是否已中止
if (totalTimeoutSignal?.aborted) {
throw totalTimeoutSignal.reason || new DOMException("Request aborted by total timeout or external signal", "AbortError");
}
attempts++;
const attemptController = new AbortController();
const attemptSignal = attemptController.signal;
let currentAttemptTimeoutId;
// 将总信号连接到当前尝试的信号
// 任何一个信号中止都会导致当前尝试的请求中止
const combinedSignal = totalTimeoutSignal ? AbortSignal.any([totalTimeoutSignal, attemptSignal]) : attemptSignal;
try {
// 设置单次请求超时
currentAttemptTimeoutId = setTimeout(() => {
attemptController.abort(new TimeoutError(`单次尝试超时,超过 ${timeout}ms 未响应`));
}, timeout);
// 将 combinedSignal 传递给 fetch
const response = await fetch(input, { ...fetchOptions, signal: combinedSignal });
if (!response.ok) {
const error = new Error(`HTTP 错误: ${response.status} ${response.statusText}`);
error.response = response;
throw error;
}
return response; // 请求成功,返回响应
} catch (error) {
// 清除当前尝试的超时定时器
if (currentAttemptTimeoutId) {
clearTimeout(currentAttemptTimeoutId);
}
// 如果是 DOMException("AbortError") 且不是 TimeoutError,
// 那么可能是 externalSignal 或 totalTimeoutSignal 导致的中止
if (error instanceof DOMException && error.name === 'AbortError' && !(error instanceof TimeoutError)) {
// 检查 abort 的原因,如果是总超时,则包装为 TimeoutError
if (totalTimeoutSignal?.aborted && totalTimeoutSignal.reason) {
throw totalTimeoutSignal.reason; // 抛出总超时错误
}
throw error; // 否则抛出原始的 AbortError
}
// 判断是否应该重试
if (attempts <= maxRetries && retryOnPredicate(error)) {
console.warn(`第 ${attempts}/${maxRetries + 1} 次尝试失败,错误: ${error.message || error.name}。准备重试...`);
if (onRetryAttempt) {
onRetryAttempt(attempts, error);
}
// 计算延迟
let currentDelay = retryDelay * Math.pow(retryFactor, attempts - 1);
if (retryJitter > 0) {
const jitter = Math.random() * Math.min(currentDelay, retryJitter);
currentDelay = Math.floor(jitter);
}
await delay(currentDelay, totalTimeoutSignal); // 延迟也应该能被总信号中断
} else {
// 不再重试,抛出最终错误
console.error(`所有尝试失败或遇到不可重试错误。最终错误:`, error);
throw error;
}
}
}
} finally {
// 清理总 AbortController 的资源
// totalTimeoutController.abort() 已经在 totalTimeout 定时器中调用,
// 或者在外部信号触发时调用。这里不需要重复调用
}
// 如果循环结束但没有返回或抛出错误,说明逻辑有误
throw new Error("RobustFetch 意外结束,未返回结果或抛出错误。");
}
完整代码解析:
TimeoutError和delay改进:delay函数现在也接受AbortSignal,这意味着如果总超时信号触发,即使在延迟等待期间,delay也会立即拒绝。- 顶层
AbortController(totalTimeoutController):robustFetch函数现在在开始时创建一个totalTimeoutController。- 如果
options.totalTimeout被设置,一个setTimeout会在指定时间后调用totalTimeoutController.abort(),并附带一个TimeoutError作为原因。 - 如果
options.signal(外部信号)被提供,它会被监听,当外部信号中止时,totalTimeoutController也会中止。这确保了外部取消可以停止整个重试过程。
- 单次尝试的
AbortController(attemptController):- 每次循环迭代(即每次尝试)都会创建一个新的
attemptController。 timeout选项用于为这次attemptController设置定时器,如果超时,它会中止当前尝试。
- 每次循环迭代(即每次尝试)都会创建一个新的
AbortSignal.any()(Chrome 105+ / Node.js 18.17.0+):- 为了实现“任何一个信号中止都会中止请求”的逻辑,我们使用
AbortSignal.any()来组合totalTimeoutSignal和attemptSignal。这个组合信号combinedSignal被传递给fetch。这意味着如果总超时发生,或者单次尝试超时,或者外部信号中止,当前的fetch请求都会被取消。 - 兼容性注意:
AbortSignal.any()相对较新。如果需要支持旧版浏览器或Node.js,需要手动实现信号的级联监听。这里为简洁和现代实践使用它。- 替代方案(旧版本兼容): 可以通过在一个新的
AbortController中手动监听totalTimeoutSignal和attemptSignal的abort事件来实现。// 兼容性替代方案 // const combinedController = new AbortController(); // const combinedSignal = combinedController.signal; // totalTimeoutSignal?.addEventListener('abort', () => combinedController.abort(totalTimeoutSignal.reason), { once: true }); // attemptSignal.addEventListener('abort', () => combinedController.abort(attemptSignal.reason), { once: true }); // // 然后将 combinedSignal 传递给 fetch
- 替代方案(旧版本兼容): 可以通过在一个新的
- 为了实现“任何一个信号中止都会中止请求”的逻辑,我们使用
- 错误处理与原因传播:
- 当
fetch因AbortSignal而中止时,它会抛出DOMException,其name为"AbortError"。我们通过检查totalTimeoutSignal.reason来区分是总超时、单次超时还是外部取消导致的中止。 defaultRetryOnPredicate也被更新,以更好地处理这些AbortError的情况。
- 当
- 资源清理:
- 单次尝试的
setTimeout在catch块或成功返回前被清除。 totalTimeoutController不需要在finally中显式清理,因为它的abort方法已经被调用,且AbortController实例本身会被垃圾回收。
- 单次尝试的
实际应用示例
现在,我们用一个实际的 fetch 请求来演示 robustFetch 的强大功能。
// 假设这里有一个模拟的 API endpoint
const MOCK_API_URL = 'https://mock.api/data';
// 模拟 fetch 函数,用于测试,它可以模拟不同类型的错误
async function mockFetch(url, options) {
const signal = options?.signal;
const { simulateDelay = 500, simulateError = false, errorType = 'network' } = options;
return new Promise((resolve, reject) => {
if (signal?.aborted) {
return reject(signal.reason || new DOMException("Request aborted", "AbortError"));
}
const timeout = setTimeout(() => {
if (signal?.aborted) {
return reject(signal.reason || new DOMException("Request aborted", "AbortError"));
}
if (simulateError) {
if (errorType === 'network') {
reject(new TypeError('Failed to fetch'));
} else if (errorType === '500') {
const err = new Error('Internal Server Error');
err.response = { status: 500, statusText: 'Internal Server Error' };
reject(err);
} else if (errorType === '400') {
const err = new Error('Bad Request');
err.response = { status: 400, statusText: 'Bad Request' };
reject(err);
} else {
reject(new Error('未知模拟错误'));
}
} else {
resolve({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
message: `Success from ${url} after ${simulateDelay}ms`,
timestamp: new Date().toISOString()
}),
text: async () => `Success from ${url} after ${simulateDelay}ms`
});
}
}, simulateDelay);
signal?.addEventListener('abort', () => {
clearTimeout(timeout);
reject(signal.reason || new DOMException("Request aborted", "AbortError"));
}, { once: true });
});
}
// 替换全局的 fetch 函数为我们的 mockFetch,方便测试
// global.fetch = mockFetch; // 在 Node.js 环境下需要这样
// 在浏览器环境中,直接调用 mockFetch 即可,或者用一个Wrapper
const originalFetch = global.fetch; // 保存原始 fetch
// 实际测试用例
(async () => {
console.log("n--- robustFetch 实际应用测试 ---");
// 场景1: 正常请求,无错误,无超时
console.log("n--- 场景1: 正常请求 ---");
try {
const response = await robustFetch(MOCK_API_URL, {
fetch: mockFetch, // 传入模拟的 fetch 函数
simulateDelay: 200,
maxRetries: 1,
timeout: 500 // 单次超时
});
const data = await response.json();
console.log("场景1成功:", data.message);
} catch (error) {
console.error("场景1失败:", error.name, error.message, error.response?.status);
}
// 场景2: 单次请求超时,重试后成功
console.log("n--- 场景2: 单次请求超时,重试后成功 ---");
let attemptCount2 = 0;
try {
const response = await robustFetch(MOCK_API_URL, {
fetch: async (input, init) => { // 每次尝试模拟不同的延迟
attemptCount2++;
const delay = attemptCount2 === 1 ? 600 : 200; // 第一次超时,第二次成功
return mockFetch(input, { ...init, simulateDelay: delay });
},
maxRetries: 1, // 允许重试1次
timeout: 500, // 单次超时 500ms
onRetryAttempt: (attempt, error) => {
console.log(`场景2重试回调:第 ${attempt} 次重试,错误: ${error.name}`);
}
});
const data = await response.json();
console.log("场景2成功:", data.message);
} catch (error) {
console.error("场景2失败:", error.name, error.message, error.response?.status);
}
// 场景3: 模拟网络错误,重试后成功(指数退避+抖动)
console.log("n--- 场景3: 模拟网络错误,重试后成功 ---");
let attemptCount3 = 0;
try {
const response = await robustFetch(MOCK_API_URL, {
fetch: async (input, init) => {
attemptCount3++;
if (attemptCount3 < 3) { // 前两次模拟网络错误
return mockFetch(input, { ...init, simulateError: true, errorType: 'network' });
}
return mockFetch(input, { ...init, simulateDelay: 100 }); // 第三次成功
},
maxRetries: 2, // 允许重试2次
retryDelay: 100,
retryFactor: 2,
retryJitter: 50,
timeout: 2000,
onRetryAttempt: (attempt, error) => {
console.log(`场景3重试回调:第 ${attempt} 次重试,错误: ${error.message}`);
}
});
const data = await response.json();
console.log("场景3成功:", data.message);
} catch (error) {
console.error("场景3失败:", error.name, error.message, error.response?.status);
}
// 场景4: 达到最大重试次数,最终失败(模拟500错误)
console.log("n--- 场景4: 达到最大重试次数,最终失败 ---");
try {
const response = await robustFetch(MOCK_API_URL, {
fetch: async (input, init) => { // 总是模拟500错误
return mockFetch(input, { ...init, simulateError: true, errorType: '500' });
},
maxRetries: 2,
retryDelay: 100,
timeout: 500
});
const data = await response.json();
console.log("场景4成功:", data.message);
} catch (error) {
console.error("场景4失败:", error.name, error.message, error.response?.status); // 预期: HTTP 错误: 500
}
// 场景5: 遇到不可重试错误(400 Bad Request),立即停止重试
console.log("n--- 场景5: 遇到不可重试错误,立即停止重试 ---");
try {
const response = await robustFetch(MOCK_API_URL, {
fetch: async (input, init) => { // 第一次就模拟400错误
return mockFetch(input, { ...init, simulateError: true, errorType: '400' });
},
maxRetries: 3,
retryDelay: 100,
timeout: 500
});
const data = await response.json();
console.log("场景5成功:", data.message);
} catch (error) {
console.error("场景5失败:", error.name, error.message, error.response?.status); // 预期: HTTP 错误: 400
}
// 场景6: 整个请求过程总超时 (totalTimeout)
console.log("n--- 场景6: 整个请求过程总超时 ---");
let attemptCount6 = 0;
try {
const response = await robustFetch(MOCK_API_URL, {
fetch: async (input, init) => {
attemptCount6++;
console.log(`场景6:第 ${attemptCount6} 次尝试...`);
// 每次都模拟一个稍微慢的请求,但单次请求不会超时
return mockFetch(input, { ...init, simulateDelay: 400 });
},
maxRetries: 10, // 允许很多次重试
retryDelay: 100,
timeout: 500, // 单次超时
totalTimeout: 1000, // 但整个过程只有 1秒
onRetryAttempt: (attempt, error) => {
console.log(`场景6重试回调:第 ${attempt} 次重试,错误: ${error.name}`);
}
});
const data = await response.json();
console.log("场景6成功:", data.message);
} catch (error) {
console.error("场景6失败:", error.name, error.message); // 预期: TimeoutError 整个请求过程超时...
}
// 场景7: 外部 AbortController 取消
console.log("n--- 场景7: 外部 AbortController 取消 ---");
const externalController = new AbortController();
const externalSignal = externalController.signal;
setTimeout(() => {
console.log("场景7: 外部 AbortController 触发取消...");
externalController.abort(new Error("External Cancellation Triggered"));
}, 800);
try {
const response = await robustFetch(MOCK_API_URL, {
fetch: async (input, init) => {
console.log("场景7: 内部 fetch 尝试...");
// 模拟一个较慢的请求
return mockFetch(input, { ...init, simulateDelay: 1500 });
},
maxRetries: 3,
retryDelay: 100,
timeout: 2000,
signal: externalSignal // 传入外部信号
});
const data = await response.json();
console.log("场景7成功:", data.message);
} catch (error) {
console.error("场景7失败:", error.name, error.message); // 预期: Error External Cancellation Triggered
}
// 恢复全局 fetch
// global.fetch = originalFetch;
})();
进阶优化与细节打磨
错误类型统一与可读性
我们已经定义了TimeoutError。对于重试次数用尽导致的失败,也可以定义一个专门的错误类型,例如RetryExhaustedError,让错误处理更加清晰。
class RetryExhaustedError extends Error {
constructor(message = "所有重试尝试均失败", originalError) {
super(message);
this.name = 'RetryExhaustedError';
this.originalError = originalError; // 记录导致最终失败的原始错误
if (Error.captureStackTrace) {
Error.captureStackTrace(this, RetryExhaustedError);
}
}
}
// 在 robustFetch 中,当 attempts > maxRetries 时抛出
// ...
// } else {
// // 不再重试,抛出最终错误
// console.error(`所有尝试失败或遇到不可重试错误。最终错误:`, error);
// throw new RetryExhaustedError("所有重试尝试均失败", error);
// }
// ...
可观测性:回调与事件
onRetryAttempt回调函数已经为我们提供了重试过程中的可见性。还可以添加其他回调,例如:
onSuccess(response): 请求最终成功时onError(error): 请求最终失败时onAbort(reason): 请求被中止时
这些回调可以帮助我们集成日志系统、监控工具或向用户提供实时反馈。
资源清理与内存管理
AbortController和setTimeout的清理是关键。确保:
- 所有
setTimeout在不再需要时都被clearTimeout。 AbortController的addEventListener使用{ once: true }或在不再需要时手动removeEventListener,避免内存泄漏。
我们当前的实现已经考虑到了这些点,通过clearTimeout和{ once: true }来管理。
类型定义(TypeScript)
对于大型项目,使用TypeScript可以显著提高代码的可维护性和健壮性。为RobustFetchOptions、robustFetch函数及其参数和返回值提供精确的类型定义,可以确保在编译时捕获许多潜在错误。
// 示例 TypeScript 类型定义
interface RobustFetchOptions extends RequestInit {
maxRetries?: number;
retryDelay?: number;
retryFactor?: number;
retryJitter?: number;
timeout?: number;
retryOnPredicate?: (error: Error) => boolean;
onRetryAttempt?: (attemptNumber: number, error: Error) => void;
totalTimeout?: number;
signal?: AbortSignal;
// 允许传入自定义的 fetch 实现,便于测试或兼容不同环境
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
declare class TimeoutError extends Error {
name: 'TimeoutError';
constructor(message?: string);
}
declare class RetryExhaustedError extends Error {
name: 'RetryExhaustedError';
originalError?: Error;
constructor(message?: string, originalError?: Error);
}
// robustFetch 函数签名
function robustFetch(input: RequestInfo | URL, options?: RobustFetchOptions): Promise<Response>;
展望未来:更高级的健壮性模式
我们已经构建了一个强大的超时与重试封装,但网络请求的健壮性设计是一个广阔的领域,还有许多更高级的模式可以探索:
- 熔断器 (Circuit Breaker):当某个服务持续失败时,熔断器模式可以“短路”对该服务的请求,直接返回错误,避免对已经过载或故障的服务造成进一步压力。在一段时间后,它会尝试性地允许少量请求通过,以检测服务是否恢复。
- 限流 (Rate Limiting):在客户端侧限制对某个API或服务的请求频率,防止因请求过快而触发服务器的限流或加重服务器负担。
- 缓存策略 (Caching):对于不经常变化的数据,使用客户端缓存可以减少网络请求,提高响应速度和用户体验,同时减轻服务器压力。
- 幂等性 (Idempotency):设计API时确保重复的请求不会导致额外的副作用。例如,一个“创建订单”的API,如果因为网络问题导致请求重复发送,应该只创建一个订单。
- 批量请求与并行请求控制:优化请求模式,例如将多个小请求合并为一个大请求(批量),或限制同时进行的请求数量(并行请求控制),以提高效率和避免资源耗尽。
这些模式通常在更复杂的分布式系统或微服务架构中发挥作用,但其基本思想也可以应用于前端应用,进一步提升用户体验和系统稳定性。
通过本文的深入探讨和代码实践,我们理解了网络请求健壮性设计的必要性,并成功构建了一个集超时与重试于一体的Promise封装函数。这个robustFetch函数能够有效应对瞬时网络故障和服务器不稳定,为用户提供更加流畅可靠的应用体验。