首页
/ Axios 请求重试与错误恢复:基于响应拦截器实现重试、指数退避与取消

Axios 请求重试与错误恢复:基于响应拦截器实现重试、指数退避与取消

2026-09-05 10:53:24作者:袁立春Spencer

网络请求会因瞬时原因而失败——服务器短暂抖动、网络中断、速率限制响应等。本文围绕 Axios 官方文档中「Retry and error recovery」一节,完整讲解如何在响应拦截器中落地一套透明的重试策略:基础重试、指数退避、基于 Retry-After 的 429 限流重试、按请求关闭重试,以及将重试等待期与 AbortController 取消机制相结合。读完后你可以直接在生产代码中复用这些模式,并从 Axios 源码层面理解拦截器执行、信号合成与头部解析的细节。

为什么把重试逻辑放在响应拦截器里

重试是「横切关注点」:它应该对所有经过某个实例的请求生效,而不是散落在每个业务调用点。Axios 的实例拦截器机制正好提供了这样的挂载点——通过 axios.create() 创建的实例拥有独立的拦截器栈,重试逻辑只对该实例的请求生效,不影响全局默认实例。

从源码结构看,每个实例的请求与响应拦截器都由一个 InterceptorManager 管理。在 InterceptorManager.js 中,use(fulfilled, rejected, options) 方法会把两个回调连同 synchronousrunWhen 选项压入 handlers 栈并返回一个自增 ID(可用于后续 eject(id) 移除)。这意味着:

  • 响应拦截器的 rejected 回调就是文档示例中处理重试的那个函数;
  • 多个响应拦截器会按注册顺序依次执行,重试拦截器应注册在依赖「最终失败结果」的拦截器之前,才能让上游逻辑感知到重试已耗尽;
  • 返回 Promise.reject(error) 表示把失败继续向外抛,返回一个新的 Promise(如重新发起请求)则把整个请求链路「接住」并重放。

理解这一点后,再看下面的具体实现就容易了。

基础重试:捕获特定错误并有限次重发

最简单的做法是:捕获特定错误状态码,并把原始请求原样重发有限次数。完整实现如下(继承自官方文档示例):

import axios from "axios";

const api = axios.create({ baseURL: "https://api.example.com" });

const MAX_RETRIES = 3;

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const config = error.config;

    // Only retry on network errors or 5xx server errors
    const shouldRetry =
      !error.response || (error.response.status >= 500 && error.response.status < 600);

    if (!shouldRetry) {
      return Promise.reject(error);
    }

    config._retryCount = config._retryCount ?? 0;

    if (config._retryCount >= MAX_RETRIES) {
      return Promise.reject(error);
    }

    config._retryCount += 1;
    return api(config);
  }
);

这个模式有几个关键细节值得展开:

  1. error.config 是可复用的重放载体。 当请求失败时,Axios 会把完整的请求配置挂在错误对象上。再次调用 api(config) 时,整个请求(URL、方法、头部、请求体、baseURL 等)都会被原样重发——不需要在业务层保存任何「请求快照」。
  2. 重试判定条件 shouldRetry !error.response 表示请求根本没有得到 HTTP 响应(典型的网络层错误:连接被拒、DNS 失败、超时等);error.response.status >= 500 && < 600 表示服务器端 5xx 错误。这两类都是「瞬时失败」的典型代表,而 4xx(如 400、401、404)通常意味着请求本身有问题,重试没有意义,应直接向上抛出。
  3. config._retryCount 在配置对象上计数。 这是利用 config 在重试链路中被反复传递的特性:每次进入错误处理时读取、递增、写回,天然形成每请求独立的重试计数器。注意 ?? 0 空值合并写法保证了首次进入时计数器从 0 开始。
  4. MAX_RETRIES 决定「额外重试次数」。 上述配置下,一个请求最多会发起 1(首次)+ 3(重试)= 4 次网络调用;超过 3 次重试后调用 Promise.reject(error) 把最终错误交给业务层。

指数退避:避免压垮本已吃力的服务器

失败后立即重试,可能让一个正在挣扎的服务器雪上加霜。指数退避(Exponential Backoff)的做法是:每次重试前等待的时间成倍增长。完整实现:

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const config = error.config;

    const shouldRetry =
      !error.response || (error.response.status >= 500 && error.response.status < 600);

    if (!shouldRetry) return Promise.reject(error);

    config._retryCount = config._retryCount ?? 0;

    if (config._retryCount >= 3) return Promise.reject(error);

    config._retryCount += 1;

    // Wait 200ms, 400ms, 800ms, ... before each retry
    const backoff = 100 * 2 ** config._retryCount;
    await delay(backoff);

    return api(config);
  }
);

要点说明:

  • 等待时长公式为 100 * 2 ** n 毫秒n 为递增后的重试序号):第 1 次重试前等 200ms,第 2 次等 400ms,第 3 次等 800ms,呈 2 的幂增长。基数 100ms 与最大重试次数都应根据目标服务的恢复时间窗口自行调整。
  • delay 辅助函数setTimeout 包装成 Promise,使 async 拦截器回调可以 await 一个纯等待,写法清晰且不阻塞事件循环。
  • 由于整个错误处理函数是 async 的,等待发生在 Promise 链内部,业务侧的 await api.get(...) 会自然地挂起直到重试完成或重试耗尽——对调用方完全透明。
  • 实际项目中还可以在此基础上叠加「抖动」(jitter,在等待时长上叠加随机量),避免大量客户端在同一时刻齐步重试。文档示例本身未包含抖动,这里作为可扩展方向说明。

针对 429 限流响应的重试:读取 Retry-After

当服务器返回 429 Too Many Requests 时,响应头中通常会携带 Retry-After,精确告诉客户端应该等待多久。针对这类响应的重试实现如下:

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const config = error.config;

    if (error.response?.status !== 429) return Promise.reject(error);

    config._retryCount = config._retryCount ?? 0;
    if (config._retryCount >= 3) return Promise.reject(error);

    config._retryCount += 1;

    const retryAfterHeader = error.response.headers["retry-after"];
    const waitMs = retryAfterHeader
      ? parseFloat(retryAfterHeader) * 1000  // header is in seconds
      : 1000;                                // default to 1 second

    await new Promise((resolve) => setTimeout(resolve, waitMs));
    return api(config);
  }
);

细节拆解:

  • 只处理 429。 error.response?.status !== 429 时立即 reject,说明 429 与 5xx 的重试策略应分开设计:429 有服务器指定的等待时长,5xx 则需要自己估算退避。
  • Retry-After 的语义是「秒数」。 示例中用 parseFloat(retryAfterHeader) * 1000 把它换算成毫秒。需要留意的是,HTTP 规范允许 Retry-After 取「秒数」或「HTTP-date 日期字符串」两种形式;示例只处理了秒数形式,若对接的 API 会返回日期形式,需要自行补充解析。
  • 缺省回退 1 秒。 服务器没有给出 Retry-After 时等待 1000ms 再重试,保证逻辑不会因缺少头部而卡死。
  • 头部键是统一小写的。 在 Axios 内部,响应头会被解析为键名全部小写的对象。这一点可以从 parseHeaders.js 的源码确认:解析逻辑对每个头部行执行 line.substring(0, i).trim().toLowerCase() 得到键名,并且对 Node 环境下会重复出现的头部(retry-after去重忽略列表 中)只保留首个值。因此代码中直接用小写 headers["retry-after"] 取值是可靠且跨适配器一致的。

按请求关闭重试:非幂等请求的保险栓

有些请求绝不能重试——最典型的是你不希望重复执行的非幂等变更操作(例如扣款)。做法是在请求配置上打一个标志位,拦截器在重试逻辑之前先检查它:

// Add this to your interceptor before the retry logic:
if (config._noRetry) return Promise.reject(error);

// Then opt out on specific calls:
await api.post("/payments/charge", body, { _noRetry: true });

这个机制的原理在于 Axios 允许在请求配置上携带任意自定义字段,它们会原样保存在 config 中,并在失败时通过 error.config 继续可访问——_retryCount 的计数也是依赖同一机制的。因此「实例级统一重试 + 请求级按例豁免」可以组合成一套清晰的策略:

场景 处理
GET 等幂等读请求 默认享受实例级自动重试
非幂等写请求(扣款、下单等) { _noRetry: true } 显式退出重试
重试耗尽 Promise.reject(error) 交回业务层最终处理

重试与取消的组合:AbortController 贯穿退避等待

指数退避的一个副作用是:请求失败后,客户端会进入一段可能长达数秒的等待期。如果用户此时已经放弃这次操作(离开页面、点击取消),这段等待应当可以被立即打断。Axios 原生支持标准 AbortController/AbortSignal,完整用法如下:

const controller = new AbortController();

try {
  await api.get("/api/data", { signal: controller.signal });
} catch (error) {
  if (axios.isCancel(error)) {
    console.log("Request aborted by user");
  }
}

// Cancel the request (and any pending retry delay) from elsewhere:
controller.abort();

从源码层面看这套机制是如何工作的:

  • 取消判定的依据是一个内部标记。 axios.isCancel(error) 的实现极其简单,见 isCancel.js:只要错误对象带有 __CANCEL__ 属性就返回 true。被取消的请求会抛出带该标记的 CanceledError,业务层据此把它与普通网络错误区分开,避免把「用户主动取消」误报为失败。
  • signaltimeout 会被合成成同一个内部信号。composeSignals.js 中,Axios 把用户传入的 signal 数组与超时计时器统一监听,任何一个触发都会 controller.abort() 并向外抛出一个带原因信息的 CanceledError;超时场景的取消原因是一条 timeout of ${timeout}ms exceededAxiosError
  • 各适配器订阅这个合成信号来中止传输。 例如 Node.js 的 HTTP 适配器在 http.js 中直接监听 config.signalabort 事件(并保留了对旧版 CancelToken 的订阅兼容),一旦触发就销毁底层请求流并 reject。

需要注意的一个实践要点:拦截器里 await delay(backoff) 的纯 setTimeout 等待本身不会自动响应 signal(它只是 JS 层面的定时器)。文档示例中「controller.abort() 也能取消正在进行的退避等待」这一效果,在完整方案里通常有两种落地方式:一是在 delay 内同时监听 config.signalabort 事件并提前 resolve;二是依赖下一次重试发出时被 signal 立即中止。从示例代码结构看,文档强调的是把 AbortController 作为贯穿「请求 + 重试流程」的统一取消开关;如果你要求等待期也能被精确打断,建议在 delay 辅助函数中自行补上对 signal 的监听,这属于对文档示例的小幅增强而非原文行为。

测试与相关文档

  • 冒烟测试覆盖了该实例级配置体系的通用行为,例如限流配置 maxRate 在数字、元组以及实例/请求两级合并场景下的传递验证,见 rateLimit.smoke.test.js;浏览器侧的拦截器行为可在 interceptors.browser.test.js 中找到对应回归用例。
  • 429 场景常与「限速」一起出现,若你还需要对上传/下载带宽做限制,可参考 Node.js HTTP 适配器支持的 maxRate 用法:rate-limiting.md
  • 拦截器的完整 API 与注册/移除语义(use/eject)可继续深入 interceptors.md,核心实现在 InterceptorManager.js

小结

围绕 error.config 的「可重放」特性,Axios 的重试方案可以归纳为一个清晰的组合:

  1. 判定:只在网络错误与 5xx(或单独的 429)时重试,其余错误直接上抛;
  2. 计数config._retryCount 保证每请求独立计数且不超过 MAX_RETRIES
  3. 等待:用 100 * 2 ** n 毫秒的指数退避替代立即重发,429 场景优先采信 Retry-After 头部(秒转毫秒,缺省 1 秒);
  4. 豁免:非幂等请求通过 _noRetry 标志退出重试;
  5. 取消AbortController + axios.isCancel 提供用户主动中断的统一出口,取消错误不会被误判为网络故障。

以上模式全部基于响应拦截器与标准配置字段实现,不依赖任何第三方重试库,可直接用于浏览器与 Node.js 环境。

登录后查看全文
热门项目推荐
相关项目推荐