侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 104 篇文章
  • 累计收到 7 条评论

客户端超时放弃了,上游那笔订单还在执行

2026-9-22 / 0 评论 / 18 阅读

客户端超时放弃了,上游那笔订单还在执行

重试是接口层最容易被顺手加上去的东西:包一层循环,失败就再打一次。桩服务端把每个路径被真正执行的次数记下来之后,五种常见写法的后果都能看见,其中三种会带来实际损失。

脚本 tools/verify-retry-pitfalls.mjs,Node v26.8.1,服务端只按状态码和延迟回应,不涉及模型。

错法一:没有上限的循环,两秒打出一千多次

反例 1(不设上限,一直重试 /always500):2 秒内发出 1235 次请求,全部 500,循环没有出口

上游持续 500 时,客户端不会自己收手。1235 次请求里没有一次会成功,重试只是把上游的故障时间拉长,顺带把自己线程池占住。上限是这类写法的第一道闸。第二道闸是等待时间:立刻重发的循环在两个请求之间几乎不留间隔,上游刚被打崩的那几秒正好被补满。上限之外还要有退避,否则十三次和一千三次的区别只是撑得久一点。

同一份材料被反复盖章

错法二:Retry-After 摆在响应头里,没有一次被读

反例 2(固定 40ms 重试 5 次):耗时 224ms,服务端共被打 5 次;每次 429 都带回 Retry-After: 1,没有一次被读
        改成读 Retry-After + 指数退避:同样 3 次请求耗时 3014ms,服务端被打 3 次(慢了,但把窗口让给了上游)

429 响应带着 Retry-After: 1,固定间隔的写法完全不看它,224 毫秒里打了 5 次。改成读这个头之后,三次请求花了 3 秒,速度上慢了一个量级,服务端被打次数少了。限流场景里这个交换是划算的:慢的是本次调用,快的可能是整条链路恢复。

错法三:前一次没取消,重发变成两次执行

      第 1 次在 125ms 抛 TimeoutError(客户端已经放弃)
      第 2 次在 249ms 抛 TimeoutError(客户端已经放弃)
反例 3(超时就重发,没取消前一次):脚本重发 2 次,服务端实际执行了 2 次同一个 POST

这是唯一会造成业务后果的一条。客户端两次都在超时上限处放弃,服务端两次都收全了请求体、都跑完了 /slow 这个带副作用的路径。真实系统里对应的是重复下单、重复扣款这类事故,事后对账才发现多了一笔。

fetchAbortSignal.timeout(120) 只终止了等待,连接的写侧早已把请求送出去。超时重发要同时想清楚两件事:前一次有没有被取消,以及服务端有没有幂等键能挡住第二次。前者是客户端的事,后者只能靠上游配合,缺一个都可能重复执行。

错法四:把 400 也当成「再试一次就好」

反例 4(不区分错误类型,400 也重试):4 次请求拿到 400,400,400,400,服务端被打 4 次,重试不可能改变 400 的结果

400 是请求本身的问题,同样的报文重发多少次都是同样的结果。可重试的错误集合不大:连接失败、超时、429、502 和 503 这一档。白名单之外的错误码重试,纯粹是把一个立刻能看到的报错延迟成四条日志。

错法五:把用过的 Request 对象再发一次

反例 5(复用同一个 Request 重发):TypeError: Cannot construct a Request with a Request object that has already been used.,服务端这次被执行 0 次
       字符串体的 Request 重发:TypeError: Cannot construct a Request with a Request object that has already been used.

这条反倒不危险,因为它在本地就抛了,一次都没有发出去。但报错信息容易让人误判成网络问题,实际是请求体已经被读过。字符串体也是一样的结果,Request 一旦进过 fetch 就不能复用,重试要重新构造一个。

能被接受的那一版

正例(上限 3 次 + 每次 150ms 超时):762ms 后放弃,抛出 TimeoutError,服务端被打 3 次

=== 服务端记账(每个路径被真正执行的次数)===
   /always500   1235 次
   /429         8 次
   /slow        2 次
   /always400   4 次
   /side        2 次
   /hang        3 次

三次上限、每次 150 毫秒超时、指数退避,最后在 762 毫秒放弃。真要做重试,这四条是底子:重试集合限定在连接层错误与 429、502、503;次数有上限;每次等待递增并且读 Retry-After;对带副作用的接口必须有幂等键或者干脆不重试。

退避的间隔建议带一点随机抖动,否则同一批客户端会在同一毫秒一起回来,把上游的恢复窗口又打掉一次。这属于常见做法,本机这次没测,脚本里的退避是固定倍数。

最后一条我倾向于从严:写类接口在没有幂等键的情况下,宁可把失败抛给调用方,也不要自动重发。读接口重试三次和写接口重试三次,风险完全不是一个量级。反过来,如果上游愿意配合幂等键,重试就变成一件纯赚的事,前提是键要由调用方生成,并且在服务端真的落到唯一约束上,而不是只记在日志里。

    🤞 分享