手写实现一个具备超时与重试机制的 Promise 封装函数:处理网络请求的健壮性

开篇:网络请求的脆弱性与健壮性设计的必要性

在构建现代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));

重试机制的基石:循环与递归

重试机制的核心在于当一个操作失败时,尝试再次执行它。这可以通过两种主要方式实现:

  1. 循环 (Loop):使用forwhile循环来迭代执行操作,直到成功或达到最大重试次数。async/await的出现使得在循环中使用异步操作变得非常直观。
  2. 递归 (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])
  • timeout Promise内部使用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函数提供了一个默认的错误判断逻辑。它考虑了TimeoutErrorDOMException("AbortError")(表示请求被主动取消)、TypeError: Failed to fetch(常见的网络中断错误),以及HTTP 5xx 状态码。
  • withRetryAdvancedcatch块的开头调用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 整个重试过程的总超时时间(毫秒)。如果整个过程超出此时间,将抛出错误。默认为无总超时。

单次尝试的超时与整体请求的超时

这里有一个重要的设计决策:

  1. 单次尝试的超时 (timeout): 每次重试都应该有自己的超时时间,防止某次尝试卡死。
  2. 整体请求的超时 (totalTimeout): 整个重试过程,从第一次尝试到最后一次尝试,都应该有一个总体的最大时间限制。这可以防止在极端情况下,即使每次重试都很快,但由于重试次数过多,导致整个操作耗时过长。

核心逻辑的迭代构建

我们将分步构建robustFetch

1. 辅助函数 delayTimeoutError

这些已经在前面定义过,这里直接使用。

// 已在前面定义
// 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请求。

改进 totalTimeoutAbortController 机制

为了更好地处理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 意外结束,未返回结果或抛出错误。");
}

完整代码解析:

  1. TimeoutErrordelay 改进delay 函数现在也接受 AbortSignal,这意味着如果总超时信号触发,即使在延迟等待期间,delay 也会立即拒绝。
  2. 顶层 AbortController (totalTimeoutController)
    • robustFetch 函数现在在开始时创建一个totalTimeoutController
    • 如果 options.totalTimeout 被设置,一个 setTimeout 会在指定时间后调用 totalTimeoutController.abort(),并附带一个 TimeoutError 作为原因。
    • 如果 options.signal(外部信号)被提供,它会被监听,当外部信号中止时,totalTimeoutController 也会中止。这确保了外部取消可以停止整个重试过程。
  3. 单次尝试的 AbortController (attemptController)
    • 每次循环迭代(即每次尝试)都会创建一个新的 attemptController
    • timeout 选项用于为这次 attemptController 设置定时器,如果超时,它会中止当前尝试。
  4. AbortSignal.any() (Chrome 105+ / Node.js 18.17.0+):
    • 为了实现“任何一个信号中止都会中止请求”的逻辑,我们使用 AbortSignal.any() 来组合 totalTimeoutSignalattemptSignal。这个组合信号 combinedSignal 被传递给 fetch。这意味着如果总超时发生,或者单次尝试超时,或者外部信号中止,当前的 fetch 请求都会被取消。
    • 兼容性注意: AbortSignal.any() 相对较新。如果需要支持旧版浏览器或Node.js,需要手动实现信号的级联监听。这里为简洁和现代实践使用它。
      • 替代方案(旧版本兼容): 可以通过在一个新的 AbortController 中手动监听 totalTimeoutSignalattemptSignalabort 事件来实现。
        // 兼容性替代方案
        // 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
  5. 错误处理与原因传播
    • fetchAbortSignal 而中止时,它会抛出 DOMException,其 name"AbortError"。我们通过检查 totalTimeoutSignal.reason 来区分是总超时、单次超时还是外部取消导致的中止。
    • defaultRetryOnPredicate 也被更新,以更好地处理这些 AbortError 的情况。
  6. 资源清理
    • 单次尝试的 setTimeoutcatch 块或成功返回前被清除。
    • 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): 请求被中止时

这些回调可以帮助我们集成日志系统、监控工具或向用户提供实时反馈。

资源清理与内存管理

AbortControllersetTimeout的清理是关键。确保:

  • 所有setTimeout在不再需要时都被clearTimeout
  • AbortControlleraddEventListener使用{ once: true }或在不再需要时手动removeEventListener,避免内存泄漏。

我们当前的实现已经考虑到了这些点,通过clearTimeout{ once: true }来管理。

类型定义(TypeScript)

对于大型项目,使用TypeScript可以显著提高代码的可维护性和健壮性。为RobustFetchOptionsrobustFetch函数及其参数和返回值提供精确的类型定义,可以确保在编译时捕获许多潜在错误。

// 示例 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函数能够有效应对瞬时网络故障和服务器不稳定,为用户提供更加流畅可靠的应用体验。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注