超时了,再发一次安全吗?
超时不等于失败。请求可能没到、可能做完了只是响应丢了、也可能还在做。重试要回答三个问题:等多久(退避 + 抖动)、总共试几次(层层相乘的放大),以及再发一次会不会多出一份副作用(幂等键)。
测试 Agent 提交 Bug 报告时超时,harness 自动重试,Bug 库里就多了一份一模一样的报告,误报率被算高了。这一章把这个场景做成真的 HTTP 服务和 SQLite,在服务端注入故障,用测试把"重复副作用"抓出来。
1超时的三种真相
客户端等了 2 秒没收到响应,只知道"没收到",不知道服务端发生了什么:
| 真相 | 服务端状态 | 盲目重试的后果 |
|---|---|---|
| 请求没到(连接都没建立) | 什么都没发生 | 没问题 |
| 到了,做完了,响应在路上丢了 | 报告已经入库 | 重复:再入库一份 |
| 到了,还在做(慢) | 处理中 | 并发重复:两个请求同时在做 |
RFC 9110 §9.2.2 的定义:一个方法是幂等的,指"多次相同请求对服务端的预期效果和一次相同"。PUT、DELETE 和安全方法(GET、HEAD 等)是幂等的,POST 不是。规范的要求是:客户端 SHOULD NOT 自动重试非幂等方法的请求,除非它有办法知道这个请求实际上是幂等的,或者能检测到原请求没有生效。
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 文档示例里不带引号。
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 特有的坑:重试有两层
- harness 层:同一个 tool_use 里 HTTP 失败,自动重发。这一层能保证用同一个键,比如
run_id:tool_use_id。 - 模型层: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)一致。选一个故障场景和一种写法,单步看每一次请求、服务端做了什么、库里有几份报告。
✎练习
backoff(attempt) 是 200ms × 2attempt−1 再乘 [0.9, 1.1)。第 4 次重试的名义等待(不算抖动)是多少毫秒?naive 服务上断言"出现了 2 份报告",再在正确实现上断言"只有 1 份"?6面试要点
一句话讲清楚
超时不等于失败,所以重试前先问"再发一次会不会多一份副作用"。会的话,客户端给每次逻辑操作生成一个幂等键、重试复用它,服务端把"记下键"和"执行副作用"放进同一个事务,同键重放、不同请求体 422、处理中 409。等多久用带抖动的指数退避,很多客户端一起失败时抖动要够大(Full Jitter),重试尽量只在一层做,否则层层相乘。测试上:在服务端注入"提交后丢响应""两次提交之间崩溃""同键并发",断言库里只有一份,并且先在错误实现上看到它变红。
追问准备
- 副作用不在你的数据库里怎么办?比如要调第三方发通知,没法和"记下键"放进同一个事务。办法是把幂等键继续传给下游(下游也支持幂等键),或者先在本地事务里写一条"待发送"记录,再由单独的发送流程按记录投递(常叫 outbox 模式),投递本身也要能去重。
- 占了键之后进程被 kill -9 怎么办?占位记录带时间戳,超过租约还没完成就允许新请求接管。接管的前提是原请求真的死了,但服务端只能靠时间猜:GC 停顿或特别慢的请求过了租约也还活着。所以租约要大于"客户端超时 + 最长处理时间",再用 fencing token 兜底:每次占键 token 加 1,写回时带条件
fence=自己的 token,慢请求醒来时 token 已经过期,更新 0 行,连同报告一起回滚,改为重放接管者的结果。"报告和键在同一个事务里,要么都有要么都没有"只是单个请求内部的保证,挡不住两个请求各写一份。 - 429 要不要重试?看服务端给不给 Retry-After,以及是什么 429。第 3 周讲过 Codex 把 429 分成好几种:额度类 429 直接终止;其他 HTTP 429 只有带 Retry-After 才重试;流里的 rate_limit_exceeded 走退避。
- 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 源码,交互部分不受影响。