Axios 请求重试与错误恢复:基于响应拦截器实现重试、指数退避与取消
网络请求会因瞬时原因而失败——服务器短暂抖动、网络中断、速率限制响应等。本文围绕 Axios 官方文档中「Retry and error recovery」一节,完整讲解如何在响应拦截器中落地一套透明的重试策略:基础重试、指数退避、基于 Retry-After 的 429 限流重试、按请求关闭重试,以及将重试等待期与 AbortController 取消机制相结合。读完后你可以直接在生产代码中复用这些模式,并从 Axios 源码层面理解拦截器执行、信号合成与头部解析的细节。
为什么把重试逻辑放在响应拦截器里
重试是「横切关注点」:它应该对所有经过某个实例的请求生效,而不是散落在每个业务调用点。Axios 的实例拦截器机制正好提供了这样的挂载点——通过 axios.create() 创建的实例拥有独立的拦截器栈,重试逻辑只对该实例的请求生效,不影响全局默认实例。
从源码结构看,每个实例的请求与响应拦截器都由一个 InterceptorManager 管理。在 InterceptorManager.js 中,use(fulfilled, rejected, options) 方法会把两个回调连同 synchronous、runWhen 选项压入 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);
}
);
这个模式有几个关键细节值得展开:
error.config是可复用的重放载体。 当请求失败时,Axios 会把完整的请求配置挂在错误对象上。再次调用api(config)时,整个请求(URL、方法、头部、请求体、baseURL等)都会被原样重发——不需要在业务层保存任何「请求快照」。- 重试判定条件
shouldRetry。!error.response表示请求根本没有得到 HTTP 响应(典型的网络层错误:连接被拒、DNS 失败、超时等);error.response.status >= 500 && < 600表示服务器端 5xx 错误。这两类都是「瞬时失败」的典型代表,而 4xx(如 400、401、404)通常意味着请求本身有问题,重试没有意义,应直接向上抛出。 - 用
config._retryCount在配置对象上计数。 这是利用config在重试链路中被反复传递的特性:每次进入错误处理时读取、递增、写回,天然形成每请求独立的重试计数器。注意?? 0空值合并写法保证了首次进入时计数器从 0 开始。 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,业务层据此把它与普通网络错误区分开,避免把「用户主动取消」误报为失败。 signal与timeout会被合成成同一个内部信号。 在 composeSignals.js 中,Axios 把用户传入的signal数组与超时计时器统一监听,任何一个触发都会controller.abort()并向外抛出一个带原因信息的CanceledError;超时场景的取消原因是一条timeout of ${timeout}ms exceeded的AxiosError。- 各适配器订阅这个合成信号来中止传输。 例如 Node.js 的 HTTP 适配器在 http.js 中直接监听
config.signal的abort事件(并保留了对旧版CancelToken的订阅兼容),一旦触发就销毁底层请求流并 reject。
需要注意的一个实践要点:拦截器里 await delay(backoff) 的纯 setTimeout 等待本身不会自动响应 signal(它只是 JS 层面的定时器)。文档示例中「controller.abort() 也能取消正在进行的退避等待」这一效果,在完整方案里通常有两种落地方式:一是在 delay 内同时监听 config.signal 的 abort 事件并提前 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 的重试方案可以归纳为一个清晰的组合:
- 判定:只在网络错误与 5xx(或单独的 429)时重试,其余错误直接上抛;
- 计数:
config._retryCount保证每请求独立计数且不超过MAX_RETRIES; - 等待:用
100 * 2 ** n毫秒的指数退避替代立即重发,429 场景优先采信Retry-After头部(秒转毫秒,缺省 1 秒); - 豁免:非幂等请求通过
_noRetry标志退出重试; - 取消:
AbortController+axios.isCancel提供用户主动中断的统一出口,取消错误不会被误判为网络故障。
以上模式全部基于响应拦截器与标准配置字段实现,不依赖任何第三方重试库,可直接用于浏览器与 Node.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