超时了,再发一次安全吗?

超时不等于失败。请求可能没到、可能做完了只是响应丢了、也可能还在做。重试要回答三个问题:等多久(退避 + 抖动)、总共试几次(层层相乘的放大),以及再发一次会不会多出一份副作用(幂等键)。

测试 Agent 提交 Bug 报告时超时,harness 自动重试,Bug 库里就多了一份一模一样的报告,误报率被算高了。这一章把这个场景做成真的 HTTP 服务和 SQLite,在服务端注入故障,用测试把"重复副作用"抓出来。

1超时的三种真相

客户端等了 2 秒没收到响应,只知道"没收到",不知道服务端发生了什么:

真相服务端状态盲目重试的后果
请求没到(连接都没建立)什么都没发生没问题
到了,做完了,响应在路上丢了报告已经入库重复:再入库一份
到了,还在做(慢)处理中并发重复:两个请求同时在做

RFC 9110 §9.2.2 的定义:一个方法是幂等的,指"多次相同请求对服务端的预期效果和一次相同"。PUT、DELETE 和安全方法(GET、HEAD 等)是幂等的,POST 不是。规范的要求是:客户端 SHOULD NOT 自动重试非幂等方法的请求,除非它有办法知道这个请求实际上是幂等的,或者能检测到原请求没有生效。

提交 Bug 报告是 POST,天生不幂等。要么别自动重试,要么给它加上"能认出重复"的机制,这就是幂等键。

2等多久:指数退避和四种抖动

第 3 周读过 Codex 的重试分类和常量(「Codex 的 agent loop 比我们的多了什么?」那一章):采样层默认重试 5 次,200ms 起每次翻倍,乘 [0.9, 1.1) 的抖动。这里换一个角度:抖动到底要多大。

设第 \(n\) 次重试的封顶指数值为 \(v_n = \min(\text{cap},\ \text{base}\cdot 2^n)\),常见写法:

写法等待时间出处
无抖动\(v_n\)AWS 架构博客 Exponential Backoff And Jitter(Marc Brooker,2015)配套模拟器
Full Jitter\(\text{uniform}(0,\ v_n)\)
Equal Jitter\(v_n/2 + \text{uniform}(0,\ v_n/2)\)
Decorrelated Jitter\(s = \min(\text{cap},\ \text{uniform}(\text{base},\ 3s_{\text{prev}}))\)
Codex 采样层\(200\text{ms}\cdot 2^{n-1}\cdot\text{uniform}(0.9,\ 1.1)\),不封顶async-utils/src/backoff.rs:12-17
anthropic Python SDK 1.11.0\(\min(0.5\text{s}\cdot 2^{k},\ 8\text{s})\cdot(1-0.25\,r)\),\(k\) 是已重试次数_base_client.py:842-850

Codex 的抖动只有 ±10%,SDK 的抖动只往下(最多少 25%)。它们解决的是"同一个客户端别太快重试";要解决"很多客户端同一时刻一起失败、又同一时刻一起重试"(惊群),抖动要大得多。下面的演示和实验就是对比这件事。

▶演示 1:惊群模拟器

移植 AWS 博客配套的模拟器(合成实验):N 个客户端同时读一行的版本号再写回,版本号对不上就失败、退避后从读开始重来。网络单程延迟 |Normal(10, 2)| ms,base = 5ms,cap = 2000ms。"Codex 幅度"是指数值 × [0.9, 1.1),为了公平对比也套上同样的 cap。浏览器里实时计算,随机数和 Python 版不是同一串,数字会有小幅差别。

策略
写请求总数
全部完成用时
失败(白做)的写请求
写请求到达服务器的时间分布(单次运行;横轴是时间,纵轴是每个时间格里的请求数,格宽随总时长变化;灰色 = 第一次尝试,蓝色 = 重试。第一次尝试总是挤在开头,那是故障本身;要看的是蓝色有没有成簇)

3试几次:重试会层层相乘

Amazon Builders' Library 的 Timeouts, retries, and backoff with jitter(Marc Brooker)举过一个例子:一次调用要穿过 5 层服务才到数据库,每层都独立重试,按每层 3 次算,数据库在过载时会收到 \(3^5 = 243\) 倍的负载。第 3 周也见过同样的乘法:Codex 的 HTTP 层和采样层各重试 2 次、503 带 Retry-After 时,一共 3 × 3 = 9 次请求(不带时只有 3 次)。

测试 Agent 里最容易叠出来的三层:

层默认
SDK 自带重试(anthropic Python SDK 1.11.0)DEFAULT_MAX_RETRIES = 2,重试 408、409、429、5xx 和连接错误
你的 harness 包的重试自己写的 for 循环,比如再试 3 次
模型自己"再来一次"工具回了 is_error,模型可能换个参数或原样再调

harness 4 次尝试 × SDK 3 次尝试 = 一次逻辑调用最多 12 个 HTTP 请求,再乘上模型层的重新调用。文章的建议:对低成本的控制面和数据面操作,只在调用栈的一个位置重试;即使只有一层,出错时流量也会明显上涨,所以再用本地令牌桶限制重试(令牌够就重试,用完就按固定速率重试,AWS SDK 在 2016 年加了这个行为)。文章还说重试是"自私的":客户端用服务端更多的资源,换自己更高的成功率。

4再发一次会不会多一份:幂等键

思路:客户端给一次逻辑操作生成一个唯一的键(推荐 UUID),放在 Idempotency-Key 头里;重试时复用同一个键。服务端记下"这个键 → 第一次的结果",再看到同一个键就直接返回记下的结果,不再执行一遍。

情况IETF 草案 draft-07 的建议Stripe 的做法
同键、同请求体,上一次已完成SHOULD 返回上一次的结果(成功或错误)返回保存的状态码和响应体,包括 500
同键、不同请求体SHOULD 返回 422比较参数,不一致就报错
同键,上一次还在处理SHOULD 返回 409和并发请求冲突时不保存结果,可以重试
键的有效期资源 SHOULD 定义并公布过期策略至少 24 小时后可以清理,清理后再用同一个键会当成新请求

IETF 的 The Idempotency-Key HTTP Header Field 是 httpapi 工作组的草案,最新版 draft-07(2025-10-15)已于 2026-04-18 过期,不是 RFC。草案里键的值是结构化字段的 String(带引号,如 "8e03978e-..."),Stripe 文档示例里不带引号。

原子性:记下键和执行副作用要在同一个事务里。先插报告、再登记键,两步之间崩溃,就会留下一份"没登记的报告",重试时又插一份。下面的演示 2 里 key_split 就是这种写法。注意"同一个事务"只保证单个请求内部不留半截;占位过了租约被别的请求接管后,原来那个慢请求醒过来还会再写一份。所以占键时发一个单调递增的 fencing token,写回时 UPDATE … WHERE key=? AND status='in_progress' AND fence=?,影响行数不是 1 就整个事务回滚、报告一起撤销。

Codex 里的一个真实例子

Codex TUI 的"用掉一次额度重置"是一个会扣东西的操作。确认时生成一次 UUID(tui/src/chatwidget/usage.rs:236-237),请求失败后"Try again"按钮复用同一个键(usage.rs:400-415);协议注释写的是 "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt."(app-server-protocol/src/protocol/v2/account.rs:400-411)。后端的返回里专门有一种结果 AlreadyRedeemed:"The same idempotency key already completed a reset successfully."。测试 rate_limit_reset_retry_reuses_idempotency_key 断言的就是"响应丢了,重试带的还是原来那个键"。同一个函数里,JSON-RPC 的 request id 每次都新生成(tui/src/app/background_requests.rs:872):请求 id 标识一次传输,幂等键标识一次逻辑操作,两者不是一回事。

5Agent 特有的坑:重试有两层

  1. harness 层:同一个 tool_use 里 HTTP 失败,自动重发。这一层能保证用同一个键,比如 run_id:tool_use_id。
  2. 模型层:harness 放弃后把 is_error 回给模型,模型决定"再提交一次"。这是一个新的 tool_use,id 变了,基于 tool_use id 的键认不出来。

应对:工具结果要说"结果未知,报告可能已经创建,先查一下",而不是"提交失败";需要跨模型层去重时,用业务内容推导键(同一次评测、同一模块、规范化后的标题)。业务键也有边界:模型把标题改写了就认不出来,只能靠评测侧去重(第 6 周的 Judge)。

Codex 的采样重试从历史重新组装请求(core/src/session/turn.rs:1656-1662,第 3 周讲过),已经跑完的工具调用和输出都在历史里,不会因为采样重试再执行一遍。重试模型调用和重试工具调用是两回事:前者对外部世界没有副作用(花钱除外),后者可能有。

MCP 工具的 annotations 里有 idempotentHint("calling the tool repeatedly with the same arguments will have no additional effect on its environment",默认 false,只在 readOnlyHint 为 false 时有意义)。它只是提示:schema 注释写明 client 应把不可信 server 的 annotations 当成不可信,不能据此决定自动重试。

▶演示 2:重试会不会多出一份报告

在浏览器里模拟一个 Bug 报告服务和测试 Agent 的 harness,逻辑和本地 Python 版(bug_tracker.py / harness.py)一致。选一个故障场景和一种写法,单步看每一次请求、服务端做了什么、库里有几份报告。

故障
服务端
键来源
库里的报告份数
HTTP 请求数
结论
时间线(高亮 = 刚发生)
服务端数据库

✎练习

题 1(读源码):Codex 采样层的 backoff(attempt) 是 200ms × 2attempt−1 再乘 [0.9, 1.1)。第 4 次重试的名义等待(不算抖动)是多少毫秒?
ms
200 × 23 = 1600ms,抖动后落在 [1440, 1760)。前 5 次名义合计 0.2 + 0.4 + 0.8 + 1.6 + 3.2 = 6.2 秒。
题 2(放大):调用链有 3 层,每层遇到失败都最多尝试 3 次(1 次 + 2 次重试),各层独立。最底层的服务一直失败时,一次用户请求最多让它收到多少个请求?
个
3 × 3 × 3 = 27。层数一多就是指数级,Builders' Library 的例子是 5 层 35 = 243。办法是只在一层重试,或者给重试加预算(令牌桶)。
题 3(键怎么生成):harness 提交 Bug 报告,第一次请求的响应丢了,准备重试。幂等键应该怎么取?
A 等于没带键:服务端看到的是两个不同的键,照样插两份(演示 2 里"每次尝试新 UUID"那一行)。C 是业务去重,可以作为额外的一道防线,但标题一改写就失效,也会把两份确实不同、标题碰巧一样的报告误合并。
题 4(状态码):同一个 Idempotency-Key 的第一个请求还没处理完,第二个请求就到了(请求体相同)。按 IETF 草案,服务端应该怎么回?
草案:原请求还在处理时 SHOULD 返回 409;同一个键配了不同的请求体才是 422。B 就是并发重复。Stripe 文档的说法是:和并发执行的请求冲突时不保存结果,可以重试。
题 5(测开视角):为什么测试里要先在 naive 服务上断言"出现了 2 份报告",再在正确实现上断言"只有 1 份"?
如果故障没注入成功(比如响应没丢),客户端只发一次请求,正确实现和错误实现都只有 1 份,测试是绿的但什么也没证明。先让测试在已知有 Bug 的实现上变红,是在验证测试本身。

6面试要点

一句话讲清楚

超时不等于失败,所以重试前先问"再发一次会不会多一份副作用"。会的话,客户端给每次逻辑操作生成一个幂等键、重试复用它,服务端把"记下键"和"执行副作用"放进同一个事务,同键重放、不同请求体 422、处理中 409。等多久用带抖动的指数退避,很多客户端一起失败时抖动要够大(Full Jitter),重试尽量只在一层做,否则层层相乘。测试上:在服务端注入"提交后丢响应""两次提交之间崩溃""同键并发",断言库里只有一份,并且先在错误实现上看到它变红。

追问准备

  1. 副作用不在你的数据库里怎么办?比如要调第三方发通知,没法和"记下键"放进同一个事务。办法是把幂等键继续传给下游(下游也支持幂等键),或者先在本地事务里写一条"待发送"记录,再由单独的发送流程按记录投递(常叫 outbox 模式),投递本身也要能去重。
  2. 占了键之后进程被 kill -9 怎么办?占位记录带时间戳,超过租约还没完成就允许新请求接管。接管的前提是原请求真的死了,但服务端只能靠时间猜:GC 停顿或特别慢的请求过了租约也还活着。所以租约要大于"客户端超时 + 最长处理时间",再用 fencing token 兜底:每次占键 token 加 1,写回时带条件 fence=自己的 token,慢请求醒来时 token 已经过期,更新 0 行,连同报告一起回滚,改为重放接管者的结果。"报告和键在同一个事务里,要么都有要么都没有"只是单个请求内部的保证,挡不住两个请求各写一份。
  3. 429 要不要重试?看服务端给不给 Retry-After,以及是什么 429。第 3 周讲过 Codex 把 429 分成好几种:额度类 429 直接终止;其他 HTTP 429 只有带 Retry-After 才重试;流里的 rate_limit_exceeded 走退避。
  4. Agent 的模型自己又提交了一次怎么办?tool_use id 变了,键认不出来。工具结果要写"结果未知,先查询",必要时用业务内容推导键,最后靠评测侧去重兜底。

常见错误说法

❌ "超时了就是失败了,重试一下就好":响应可能只是丢了,服务端已经做完。
❌ "加了指数退避就不会惊群":没有抖动时,同一时刻失败的客户端会在同一时刻重试。
❌ "每次请求都带一个新的 UUID 当幂等键":键要标识一次逻辑操作,重试必须复用。
❌ "Idempotency-Key 是 HTTP 标准":IETF 那份是草案,draft-07 已过期,不是 RFC;各家实现(如 Stripe)细节不同。
❌ "先插数据,再记幂等键就行":两步之间崩溃会留下没登记的数据,重试又插一份。

本地代码:week05_长任务可靠性/code/retry_idempotency/(标准库实现,python3.13 -m pytest 跑 37 个测试)。公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。