把一次请求包进循环,失败后再发一遍,代码很容易写。困难在于:客户端看到失败,究竟说明服务端没有执行,还是只说明客户端没有拿到结果?

这次我做了一个本地实验:服务端收到 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() 默认配置如下。这些是让实验易于观察的参数,不是所有生产接口的建议值。

参数默认值含义
maxAttempts3包含首次请求,因此最多额外重试两次
totalMs4000一次逻辑操作的总预算,含请求、读响应体和等待
attemptMs1000每次请求及其响应体读取的时限
baseMs50第一次退避窗口的上限
capMs200退避窗口增长后的上限

只限制次数,遇到慢响应仍可能等待很久;只限制每次请求,则每次重试都会重新得到一段时间,加上等待后可能超过调用者能接受的总耗时。这里把总预算从第一次调用开始计算,后面的尝试共同使用它。

实现中把用户取消、总预算和单次时限组合成信号,并让请求与响应体读取共用该信号。等待阶段也接收取消信号。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、2003成功;计划等待 25、50 毫秒
一直返回 4041http-stop
一直返回 5011http-stop
一直返回 5033http-stop,达到次数上限
503,maxAttempts: 11http-stop,没有额外重试
429,Retry-After: 1,随后 2002成功;计划等待 1000 毫秒
503,Retry-After: 60,总预算 1000 毫秒1wait-exceeds-budget
503,Retry-After: 1.51unsupported-retry-after
响应头延迟 250 毫秒,单次时限 50 毫秒1attempt-timeout
200 响应头立即到达,正文结束延迟 250 毫秒,单次时限 50 毫秒1attempt-timeout
同样的慢正文,总预算 50 毫秒,单次时限 1000 毫秒1deadline
调用前已经取消0cancelled
准备等待时取消1cancelled,没有下一次请求
等待过程中取消1cancelled,没有下一次请求

慢正文测试尤其有用:已经拿到 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 日。实验结果仅指本文附带的本地脚本。