
重试是接口层最容易被顺手加上去的东西:包一层循环,失败就再打一次。桩服务端把每个路径被真正执行的次数记下来之后,五种常见写法的后果都能看见,其中三种会带来实际损失。
脚本 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 这个带副作用的路径。真实系统里对应的是重复下单、重复扣款这类事故,事后对账才发现多了一笔。
fetch 加 AbortSignal.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;对带副作用的接口必须有幂等键或者干脆不重试。
退避的间隔建议带一点随机抖动,否则同一批客户端会在同一毫秒一起回来,把上游的恢复窗口又打掉一次。这属于常见做法,本机这次没测,脚本里的退避是固定倍数。
最后一条我倾向于从严:写类接口在没有幂等键的情况下,宁可把失败抛给调用方,也不要自动重发。读接口重试三次和写接口重试三次,风险完全不是一个量级。反过来,如果上游愿意配合幂等键,重试就变成一件纯赚的事,前提是键要由调用方生成,并且在服务端真的落到唯一约束上,而不是只记在日志里。