Axios 重试与错误恢复实战:基于响应拦截器实现 Retry、指数退避与 429 限流处理
Axios 本身没有内置的“自动重试”配置项,但它的响应拦截器机制为重试策略提供了干净的挂载点。本文以 axios 仓库文档 Retry and error recovery 为主线,完整实现并逐层拆解四种生产级重试模式:基础重试、指数退避、429 + Retry-After 限流重试、按请求关闭重试,最后结合 AbortController 实现重试等待期的取消,并对照仓库源码说明每一步的底层执行路径,让你既能复制即用,又清楚每次重试在 axios 内部到底走了哪条链。
为什么把重试放在响应拦截器里
网络请求可能因为瞬时原因失败:服务器短暂抖动(5xx)、网络闪断(无响应)、限流(429)。如果在每个业务调用处手写重试循环,代码会迅速变得臃肿且不一致。而 axios 的响应拦截器天然处在“适配器拒绝 → 业务代码 catch”之间的链路上:适配器对非 2xx 状态通过 settle 构造 AxiosError 并 reject Promise,这个 reject 会沿着 Promise 链传递到响应拦截器的 rejected 回调。因此拦截器是拦截错误、改写命运(重试或放行)的最佳位置,业务代码无需感知任何重试细节。
从源码结构看,这条链路在 lib/core/Axios.js 中构建:_request 先把所有响应拦截器的 fulfilled/rejected 对收集进 responseInterceptorChain,再拼接进以 dispatchRequest 为起点的 Promise 链:
// lib/core/Axios.js(节选,构建 Promise 链的部分)
const chain = [dispatchRequest.bind(this), undefined];
chain.unshift(...requestInterceptorChain);
chain.push(...responseInterceptorChain);
promise = Promise.resolve(config);
while (i < len) {
promise = promise.then(chain[i++], chain[i++]);
}
也就是说,重试拦截器返回 Promise.reject(error) 时,错误继续向后传,最终落到你代码的 catch;而它返回 api(config) 时,则是一次全新的 request 调用——整条链(配置合并、请求拦截器、dispatchRequest、全部响应拦截器)会重新走一遍。这就是“在拦截器里重试”能成立的机制基础。
拦截器注册本身由 lib/core/InterceptorManager.js 的 use(fulfilled, rejected, options) 完成,返回一个可用于 eject(id) 移除的数字 ID;每个 Axios 实例在构造时即持有独立的 request 与 response 两个拦截器栈(见 lib/core/Axios.js)。
基础重试:用响应拦截器重发失败请求
最简单的做法是捕获特定错误(网络错误或 5xx),把原始请求立即重发有限次。以下是文档给出的完整实现:
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;
// 仅对网络错误或 5xx 服务端错误重试
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);
}
);
这段代码里有几个关键细节,每一个都能在源码中找到对应:
error.config为什么可用:lib/core/settle.js 在validateStatus判定失败时,把response.config作为第三个参数传入AxiosError构造函数;网络错误(无响应)则由适配器把 config 附加到错误对象上。因此重试拦截器可以直接拿到完整配置,包括 url、headers、data,从而用api(config)原样重发。- 重试计数为什么要挂在 config 上:config 对象是贯穿整次请求生命周期的载体,且
config._retryCount ?? 0的写法(空值合并)只在字段缺失时初始化,之后每次重试都在同一个对象上累加。由于重试通过api(config)重新进入request,mergeConfig会把这份带计数的配置带下去,计数因此跨重试存活,直到>= MAX_RETRIES时return Promise.reject(error)终止。 shouldRetry的判定逻辑:!error.response覆盖连接失败、超时等无响应场景;status >= 500 && status < 600覆盖服务端错误。4xx(尤其是 400/401/403/404)通常意味着请求本身有问题,立即拒绝、不做无谓重发。
需要注意的边界:!error.response 对取消(CanceledError)同样为真,因此如果你的流程中存在用户主动取消,建议在 shouldRetry 前面加一道 if (axios.isCancel(error)) return Promise.reject(error);,避免把被取消的请求当成“网络错误”重发——isCancel 的实现只是检查错误对象上的 __CANCEL__ 标记,见 lib/cancel/isCancel.js。
指数退避:给受压的服务器留出喘息时间
失败后立即重试,对一个已经吃力的服务器是额外负担。指数退避(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;
// 每次重试前等待 200ms、400ms、800ms……
const backoff = 100 * 2 ** config._retryCount;
await delay(backoff);
return api(config);
}
);
注意 config._retryCount 在计算 backoff 之前先自增:第 1 次重试等待 100 * 2 ** 1 = 200ms,第 2 次 400ms,第 3 次 800ms,与注释一致。这个“先计数、后计算”的顺序是该公式成立的前提,照抄时不要调整自增位置。
从行为上看,await delay(backoff) 使拦截器的 rejected 异步回调在 setTimeout 之后才返回 api(config),整个重试等待发生在拦截器内部、对业务调用方完全透明——业务代码只感知到一个延迟更久的最终 resolve/reject。如果想给退避加上抖动(jitter)避免多个客户端同步重试,可以直接在 backoff 上乘一个随机系数,思路不变。
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 // 该头以秒为单位
: 1000; // 默认等待 1 秒
await new Promise((resolve) => setTimeout(resolve, waitMs));
return api(config);
}
);
两个实现细节值得展开:
- 小写的
"retry-after"为什么能取到值:axios 在适配层和dispatchRequest中都会把响应头归一化为AxiosHeaders实例,lib/core/AxiosHeaders.js 内部对头名执行name.toLowerCase()归一化,因此按小写键读取retry-after是可靠写法,无需关心服务器实际发送的是Retry-After还是retry-after。 - 缺省兜底:服务器有时只返回 429 而不给
Retry-After,此时parseFloat分支不成立,回落到 1 秒的保守默认值,配合最多 3 次重试封顶,避免无限挂起。
这个 429 处理与 axios 文档中的 Rate limiting(Node.js HTTP 适配器侧的 maxRate 带宽限制)是两个方向:maxRate 控制“我发多快”,而 429 重试处理的是“对方让我慢下来”,两者可以在同一个实例上同时使用。
按请求关闭重试:保护非幂等写操作
重试对幂等请求(典型如 GET)通常安全,但对不幂等的变更操作(如扣款、下单)重复执行可能造成业务事故。文档给出的做法是在配置上加一个标记,让指定请求豁免重试:
// 在重试拦截器的重试逻辑之前加入这一行:
if (config._noRetry) return Promise.reject(error);
// 对特定调用关闭重试:
await api.post("/payments/charge", body, { _noRetry: true });
之所以自定义字段可以直接塞进 per-request 配置,是因为 request 入口处的 mergeConfig(this.defaults, config) 对未知键不做校验性剔除,_noRetry 这类下划线前缀字段会随 config 一路带到拦截器。这条约定(下划线前缀 + 配置字段携带重试状态)与 _retryCount 是同一套模式:借助 config 对象的持久性,在拦截器闭包外也能实现“每请求私有状态”。
重试与取消结合:AbortController 中止等待中的请求
指数退避意味着请求可能在两次尝试之间沉睡数秒。此时若用户已经离开页面或不再需要结果,应当能主动中断。文档的方案是 AbortController + signal:
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");
}
}
// 从别处取消请求(以及一切进行中的重试等待):
controller.abort();
axios 对 signal 的处理入口在 lib/core/dispatchRequest.js:
function throwIfCancellationRequested(config) {
if (config.cancelToken) {
config.cancelToken.throwIfRequested();
}
if (config.signal && config.signal.aborted) {
throw new CanceledError(null, config);
}
}
throwIfCancellationRequested 会在请求发出前、适配器 resolve 后、以及适配器 reject 时被检查(同一文件第 41、56、74 行)。一旦 signal.aborted 为真,抛出携带 __CANCEL__ = true 标记的 CanceledError,业务代码即可用 axios.isCancel(error) 与真正的网络错误区分开。
对重试拦截器来说有一个实践要点:CanceledError 同样表现为“无 response 的 reject”,若不显式排除,基础重试的 !error.response 分支会把它误判为可重试的网络错误。因此完整的重试拦截器建议在判定链最前面加上 axios.isCancel 短路,这与文档 Cancellation 中取消语义保持一致。需要说明的是,拦截器内 setTimeout 等待期间本身不受 signal 自动打断,若需要“等待期间取消也能立即退出”,可以在 delay 辅助函数中监听 signal 的 abort 事件提前 resolve,属于文档未覆盖的增强方向。
小结与延伸阅读
- 重试策略完全托管在响应拦截器的
rejected回调中:判定(网络错误/5xx/429)→ 计数(config._retryCount)→ 退避(固定/指数/Retry-After)→api(config)重发整条请求链。 - 每次重发都经过完整的
request管线(配置合并、请求拦截器、dispatchRequest),因此请求拦截器里的鉴权、签名逻辑在每次重试上都会生效。 - 用
config._noRetry为非幂等写操作豁免重试;用AbortController的signal中止重试流,并用axios.isCancel在拦截器中排除取消错误。 - 相关文档可继续深入:Interceptors、Error handling、Cancellation、Promises;关键源码位置:lib/core/Axios.js(拦截器 Promise 链)、lib/core/settle.js(错误构造与
error.config来源)、lib/core/InterceptorManager.js(use注册)、lib/cancel/CanceledError.js 与 lib/cancel/isCancel.js(取消判定)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00