HTTP 请求失败后,什么时候应该重试?
把一次请求包进循环,失败后再发一遍,代码很容易写。困难在于:客户端看到失败,究竟说明服务端没有执行,还是只说明客户端没有拿到结果?
这次我做了一个本地实验:服务端收到 POST 后先把内存计数器加一,再主动断开连接。客户端连续发送两次相同请求,两次都捕获到异常,服务端计数却是二。它没有创建真实订单,也没有访问第三方接口,但已经足以展示一个危险的误判:把“结果未知”当成“没有发生”。
本文要回答的是:给一个 HTTP 客户端加重试时,哪些条件应该先满足,等待多久,哪些情况应当立刻停止?我们会实现一个范围有限的 GET 客户端,用它验证恢复路径和失败边界,最后回到写入请求为什么需要额外的协议。
1. 先分清三种“失败”
第一种是没有取得可用响应,例如连接中断或读取响应体超时。它描述客户端的观察,不能单凭这一点判断服务端是否执行过操作。
第二种是取得了 HTTP 响应,但状态不符合预期。404、429、503 都属于这个层次;具体状态需要单独检查。fetch() 不会仅仅因为收到 404 就让 Promise 拒绝,MDN 明确说明了这一行为;本文的 Node.js 实验也验证了它。因此,“只在 catch 里重试”会漏掉 HTTP 状态判断。MDN:fetch 的返回与异常
第三种是 HTTP 请求完成了,业务结果却不允许继续。例如某个接口用 200 返回“库存不足”。这是一个教学假设,不是本次实验的接口行为。若库存或输入没有改变,把相同请求再发一次并没有修复问题的依据。业务判断需要接口自己的约定,通用 HTTP 包装器看不出来。
据此,重试条件应当是一组明确的允许项,而不是“遇到任何错误”。本次实验只允许 GET 在收到 429 或 503 后再试;404、501、网络异常和超时均停止。429 表示请求过于频繁,503 可表示临时过载或维护,但这些含义并不承诺下一次一定成功。RFC 6585 §4、RFC 9110 §15.6.4
这个允许范围是实验的保守策略。真实接口可能允许对特定网络错误、502 或 504 重试,也可能有特殊的 404 恢复条件;应当依据它的契约补充,而非从状态码类别直接推断。
2. 重试的前提:重复执行意味着什么?
幂等说的是,多次相同请求的预期服务端效果与一次相同,并不要求每次响应相同。HTTP 规范也要求谨慎处理非幂等请求的自动重试。RFC 9110 §9.2.2
这对读取很直观:重复查询公开资料,资料可能更新,返回内容可以不同。不过,前提仍是接口遵守读取语义。一个把“发送通知”做成 GET 的接口,不会因为方法名称变了就获得重复执行的安全性。
对于写入,不能从超时推出“可以重做”。假如服务端已经完成写入,只是响应在返回途中丢失,第二次执行会面对已经改变的状态。应先找接口是否提供操作编号、查询结果或服务端去重机制。没有这个信息时,把重试次数设得更小只能减少可能的重复,不能消除它。
因此,本文的客户端固定使用 GET,没有开放任意 method 参数。选定安全的操作范围,是实现循环之前的决定。
3. 一个小客户端,需要两套时间限制
完整附件 http-retry-lab.mjs 同时包含客户端、临时 HTTP 服务和断言,不依赖 npm 包。2026 年 9 月 29 日实际运行环境为 macOS、Node.js v22.23.1。直接执行时只连接 127.0.0.1 的随机端口,不写文件、不调用外部服务。
保存附件到一个空目录后运行:
node --version
node http-retry-lab.mjs
导出的 getText() 默认配置如下。这些是让实验易于观察的参数,不是所有生产接口的建议值。
| 参数 | 默认值 | 含义 |
|---|---|---|
maxAttempts | 3 | 包含首次请求,因此最多额外重试两次 |
totalMs | 4000 | 一次逻辑操作的总预算,含请求、读响应体和等待 |
attemptMs | 1000 | 每次请求及其响应体读取的时限 |
baseMs | 50 | 第一次退避窗口的上限 |
capMs | 200 | 退避窗口增长后的上限 |
只限制次数,遇到慢响应仍可能等待很久;只限制每次请求,则每次重试都会重新得到一段时间,加上等待后可能超过调用者能接受的总耗时。这里把总预算从第一次调用开始计算,后面的尝试共同使用它。
实现中把用户取消、总预算和单次时限组合成信号,并让请求与响应体读取共用该信号。等待阶段也接收取消信号。Node.js 提供 AbortSignal.timeout()、AbortSignal.any(),以及可用信号取消的 Promise 定时器。Node.js v22.23.1:AbortSignal、Promise 定时器
总预算还用 performance.now() 检查经过的时间。收到非成功响应后,客户端取消不再需要的响应体,再决定是否继续;它不会为了读取一段无用的错误正文而推迟下一步。
这仍是异步程序的时间约束,不能中断阻塞事件循环的同步代码,也不能撤销服务端已经执行的操作。定时器调度也不保证毫秒级精确触发。Node.js 定时器说明
4. 退避增加的是窗口,不是固定队列
如果一批客户端同时失败,又都固定等待 100 毫秒,它们可能同时回来。随机抖动的目的,是把下一批请求的时间分散开。AWS 的退避与抖动文章用模拟展示了这种区别;本文借用其中的 full jitter 思路,没有复现其负载实验,也不引用它的性能数字。Exponential Backoff And Jitter
附件中的核心函数是:
export function fullJitter(attempt, baseMs, capMs, random = Math.random) {
const value = random();
if (!Number.isFinite(value) || value < 0 || value >= 1) {
throw new RangeError('random() must return a value in [0, 1)');
}
const ceiling = Math.min(capMs, baseMs * 2 ** (attempt - 1));
return Math.floor(value * ceiling);
}
第一次失败后从小于 50 毫秒的整数中取值,第二次失败后从小于 100 毫秒的整数中取值,随后窗口最多增长到 200 毫秒。每次具体等待可以变短;递增的是上限。
为了使断言可重复,测试把随机输入固定为 0.5,得到 25、50、100、100 毫秒。另测了输入为零、接近一和越界的情况。这里验证的是公式与分支,不是随机分布质量,也不是高并发下的服务承载能力。日志中的 plannedWaitsMs 是计算出的计划等待值,不是测量到的精确耗时。
5. Retry-After 不能随手当成毫秒
Retry-After 可以是非负整数秒,也可以是 HTTP 日期。Retry-After: 1 表示一秒,不是一毫秒。RFC 9110 §10.2.3
本实现把秒换算成毫秒;日期则用当前墙上时钟计算差值,过去的日期按零处理。解析出的建议等待与随机退避取较大值,然后与剩余总预算比较。如果服务端要求等 60 秒,调用者只剩不到一秒,就停止并返回 wait-exceeds-budget。不能把 60 秒裁成 200 毫秒继续发,那样会提前再次请求。
这里有一个会影响兼容性的限制:附件只识别现代的 IMF-fixdate 日期形式,不是完整的 HTTP 日期解析器。小数秒、负数、任意文本和不支持的日期形式会得到 unsupported-retry-after,不会被猜成零。超出安全整数范围的秒数视为无限等待,随后被预算检查拒绝。
这些选择有意让不确定输入停止。要接入真实服务,需要根据它的响应格式补足兼容性,并确认机器时钟是否可信;日期等待依赖墙上时钟,总预算则使用经过时间,两者用途不同。
6. 实际跑出的结果:能恢复,也必须能停
脚本对请求次数、停止原因和计划等待都做了断言。下表是上述环境中的实际结果;请求次数来自服务端计数器。表内的短超时覆盖了默认参数。
| 服务或调用条件 | 请求次数 | 客户端结果 |
|---|---|---|
| 503、503、200 | 3 | 成功;计划等待 25、50 毫秒 |
| 一直返回 404 | 1 | http-stop |
| 一直返回 501 | 1 | http-stop |
| 一直返回 503 | 3 | http-stop,达到次数上限 |
503,maxAttempts: 1 | 1 | http-stop,没有额外重试 |
429,Retry-After: 1,随后 200 | 2 | 成功;计划等待 1000 毫秒 |
503,Retry-After: 60,总预算 1000 毫秒 | 1 | wait-exceeds-budget |
503,Retry-After: 1.5 | 1 | unsupported-retry-after |
| 响应头延迟 250 毫秒,单次时限 50 毫秒 | 1 | attempt-timeout |
| 200 响应头立即到达,正文结束延迟 250 毫秒,单次时限 50 毫秒 | 1 | attempt-timeout |
| 同样的慢正文,总预算 50 毫秒,单次时限 1000 毫秒 | 1 | deadline |
| 调用前已经取消 | 0 | cancelled |
| 准备等待时取消 | 1 | cancelled,没有下一次请求 |
| 等待过程中取消 | 1 | cancelled,没有下一次请求 |
慢正文测试尤其有用:已经拿到 200 和第一段正文,并不代表读取完成。若收到响应头后就清掉超时,下载剩余内容仍可能长时间挂住。本实现把 response.text() 放在同一时限内,因此这条路径能够停止。
取消测试则验证另一种需求:用户不需要结果以后,客户端应退出等待,不能再补发请求。它没有验证服务端回滚;发出的请求已经进入服务端时,取消客户端无法保证服务端工作一并停止。
7. 写入反例:相同请求头不等于去重
开头的 POST 路由只做这几步:
request.resume();
request.on('end', () => { submitted++; request.socket.destroy(); });
客户端两次发送相同正文,也带着相同的 X-Demo-Key 请求头。服务端没有实现对这个头的处理。输出如下:
{"case":"response-lost-after-write","clientErrors":2,"serverActions":2,"sameDemoKey":true}
这证明了本实验的两次异常都发生在内存写入之后,也说明随便增加一个“编号”请求头没有去重效果。反例使用的是客户端单独的 POST 循环,不是把本文的 GET 客户端扩展成了写入客户端。
如果要设计可重试写入,我会把它当成服务端协议问题:同一逻辑操作在各次尝试中保留相同编号;服务端把编号与请求内容绑定,明确重复请求返回什么、处理中如何回应、编号保留多久、相同编号携带不同内容如何拒绝。去重记录与业务写入还需要协调,否则“写入完成、记录未保存”的缝隙仍可能造成重复。
这段是从反例推导出的设计建议。附件没有实现持久化去重、事务或真实业务系统,也不能据此声称任何支付、任务创建接口已经具备安全重试能力。对于已有接口,应先读它实际提供的约定,再实现客户端。
8. 上线前,还要找到谁已经在重试
给业务代码再包一层循环之前,应检查 SDK、代理和任务框架各自的策略。AWS 的可靠性指导也建议限制次数、限制总耗时,并避免在多个层次重复重试。Limit retries
设想三层调用,每层最多尝试三次,且每次都完整触发下一层的三次失败:最底层可能收到 3 × 3 × 3 = 27 次请求。这是给定假设下的次数计算,不是本文测出的线上流量。问题在于,顶层写着“三次”,并不能保证下游只见到三次。
我会先确定负责重试的一层,再记录一次逻辑操作的总耗时、实际尝试次数、各次状态、等待计划与最终停止原因。只看“最后成功了”会把额外负担藏起来;只数每次失败,又会把一次操作拆成多个错误,失去用户实际等待的视角。
附件适合学习这些控制条件,尚不是生产通用库。它拒绝重定向,只读取小文本,没有响应大小限制;实验也没有覆盖 DNS、TLS、真实公网、并发限流或 SDK 集成。要处理大文件、长期流式响应或写入业务,需要重新确定读取方式、资源限制与接口协议。
回到开头的问题:只有重复执行的效果可接受、当前失败有恢复依据、剩余预算允许继续时,再试一次才有意义。次数上限、时间预算和等待策略让这个决定可控;服务端的操作语义决定这个决定是否成立。两次报错却执行了两次的反例,应该始终留在实现旁边,提醒我们先确认结果,再讨论如何提高成功率。
文档与外部链接核对日期:2026 年 9 月 29 日。实验结果仅指本文附带的本地脚本。