Learning AI Quality 返回 KuthorX Blog II博客首页

第 24 章

第 5 周:超时了,再发一次安全吗?

测试 Agent 提交 Bug 报告超时,重试之后库里多了一份。这一章讲重试的三个问题:等多久(指数退避与四种抖动,对照 Codex 的 ±10%)、试几次(层层相乘的放大)、会不会重复(幂等键、原子性、Agent 特有的两层重试),并用真实 HTTP 服务 + SQLite 注入故障,写测试把重复副作用抓出来。一段讲解视频,一个惊群模拟器和一个幂等单步演示。

BugHunt-Bench 的测试 Agent 找到一个 Bug,调用 submit_bug_report 提交报告。请求发出去 2 秒没回音,harness 按惯例重试,这次成功了。第二天看评测结果,同一个 Bug 在库里有两份报告,其中一份被判成了"重复提交",误报率被算高了。问题出在哪:第一次请求其实已经成功入库,只是响应在路上丢了。

这一章讲重试的三个问题:等多久(退避和抖动)、总共试几次(层层叠加的放大),以及再发一次会不会多出一份副作用(幂等键)。前两个问题第 3 周读 Codex 时碰过,这里只补没讲过的部分;第三个问题是本章重点,会用本地真实的 HTTP 服务和 SQLite 注入故障,写测试把重复抓出来。

讲解视频

互动演示

两个演示。第一个是惊群模拟器:移植 AWS 博客配套的模拟器,N 个客户端同时抢着写同一行,失败就退避重试,可以切换六种策略(不退避、无抖动、Codex 幅度的 ±10%、Equal、Full、Decorrelated),看写请求总数、完成用时和请求到达的时间分布(浏览器里实时计算,随机数和本地 Python 版不是同一串,数字会有小幅差别)。第二个是幂等单步演示:选一种故障(响应丢失、服务端中途崩溃、并发重复、模型换 id 重提、模型改写标题重提)、一种服务端写法和一种键来源,单步看每次请求和数据库里的变化,最后可以一键跑完 25 种组合。页面底部有自动判分的练习。

互动演示:惊群模拟器与幂等单步 在新标签页打开

超时的三种真相

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

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

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

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

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

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

先看 Codex 采样层的退避函数原文( async-utils/src/backoff.rs:7-17 ):

const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 2.0;

/// Return a retry delay starting at 200 ms and doubling on each subsequent attempt,
/// with up to 10% jitter. Attempts zero and one both use the initial delay.
pub fn backoff(attempt: u64) -> Duration {
    let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32);
    let base = (INITIAL_DELAY_MS as f64 * exp) as u64;
    let jitter = rand::rng().random_range(0.9..1.1);
    Duration::from_millis((base as f64 * jitter) as u64)
}
  • attempt.saturating_sub(1):减 1,但不会减到负数(0 减 1 还是 0)。所以第 0 次和第 1 次都等 200ms,注释也写了。
  • powi 是整数次幂,as u64 是截断成整数。random_range(0.9..1.1) 是左闭右开区间里的均匀随机数。
  • 没有上限。重试次数调大以后,第 10 次要等 200ms × 29 ≈ 102 秒(第 3 周算过)。

本地 backoff.py 里有逐行翻译的 codex_backoff_ms,用固定种子跑前 6 次:[192, 372, 824, 1463, 3222, 6228](毫秒)。

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

写法等待时间出处
无抖动\(v_n\)AWS 模拟器 ExpoBackoff
Full Jitter\(\text{uniform}(0,\ v_n)\)AWS 模拟器 ExpoBackoffFullJitter
Equal Jitter\(v_n/2 + \text{uniform}(0,\ v_n/2)\)AWS 模拟器 ExpoBackoffEqualJitter
Decorrelated Jitter\(s = \min(\text{cap},\ \text{uniform}(\text{base},\ 3s_{\text{prev}}))\)AWS 模拟器 ExpoBackoffDecorr
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\) 是已重试次数,\(r\in[0,1)\)_base_client.py:842-850

前四行的公式来自 AWS 架构博客 Exponential Backoff And Jitter (Marc Brooker,2015-03-04)配套的模拟器源码( awslabs/aws-arch-backoff-simulator 的 backoff_simulator.py:24-52 ;博客正文里的公式是图片)。最后一行是本机安装的 anthropic SDK 1.11.0 源码:没有 Retry-After 时 0.5 秒起翻倍、封顶 8 秒,再乘 1 - 0.25 * random(),只往下抖、最多少 25%(源码注释写的是 “plus-or-minus half a second”,和代码不符,以代码为准)。

Codex 和 SDK 的抖动都不大。它们防的是"同一个客户端别重试得太密"。另一类问题是很多客户端同一时刻一起失败,又同一时刻一起重试(惊群),这时抖动要大得多才有用。

实验:把 AWS 的模拟器跑一遍

herd_sim.py 把 AWS 的模拟器从 Python 2 移植到 Python 3,显式传入随机数生成器,并多加一种"Codex 幅度"(指数值 × [0.9, 1.1),套上同样的 cap 以便对比;抖动乘在封顶之后,所以最多到 1.1 × cap)。模型是乐观并发:每个客户端先读一行的版本号,再带着版本号写回;版本号对不上就算失败,退避后从读开始重来。网络单程延迟 \(|\mathcal{N}(10, 2)|\) 毫秒,base = 5ms,cap = 2000ms,这些都和原脚本一致。只数写请求,每格 100 次取平均。这是合成实验,不代表任何真实服务。

$ python3 herd_sim.py
惊群实验(合成数据;OCC 争用,每格 100 次平均;calls = 写请求总数,ms = 全部完成用时)

策略                                       10 个客户端           50 个客户端          100 个客户端
不退避(立刻重试)                  50 calls    377 ms   690 calls   1140 ms  2427 calls   2031 ms
指数退避,无抖动                    51 calls   3534 ms   624 calls  36583 ms  1847 calls  63188 ms
指数 × [0.9,1.1)(Codex 幅度)      46 calls   1292 ms   390 calls   8122 ms   927 calls  12762 ms
Equal Jitter                        43 calls    721 ms   346 calls   4191 ms   812 calls   6620 ms
Full Jitter                         39 calls    478 ms   333 calls   2884 ms   796 calls   4956 ms
Decorrelated Jitter                 38 calls    446 ms   374 calls   2197 ms  1005 calls   4488 ms

怎么读:

  • 无抖动最差。100 个客户端时写请求 1847 次、用时 63 秒。同一时刻失败的客户端,下一次也在同一时刻重试,每一轮只有一个能写成功。这和博客的结论一致:“The no-jitter exponential backoff approach is the clear loser.”
  • ±10% 已经有用:100 个客户端时调用降到无抖动的 50%(927 / 1847),用时降到 20%。但重试仍然成簇,演示 1 的直方图里能看到一团一团的蓝色。
  • Full Jitter 调用降到无抖动的 43%,用时 5.0 秒。Decorrelated 用时最短但调用更多。博客原文对这两者的判断是 “The decision between “Decorrelated Jitter” and “Full Jitter” is less clear. The “Full Jitter” approach uses less work, but slightly more time.",50 和 100 个客户端时我们的移植版也是这个关系。
  • 不退避完成得快,但调用最多。这个模拟里服务端不会过载,所以"快”;真实服务过载时,这正是让它起不来的那种流量。
  • 客户端少时差别不大:10 个客户端时各策略的调用数都在 38 到 51 之间。抖动解决的是争用,不争用时它不太重要。

所以 Codex 的 ±10% 不算"错"。我的理解是:它的退避是每个会话各自在用,防的主要是单个会话的断流、5xx,而且 HTTP 层拿到 Retry-After 时用服务端的值(流里的错误目前只认消息里写的等待时间,见第 3 周)。但如果你写的是一批 worker 同时打同一个服务的评测 harness(BugHunt-Bench 一次并发跑几十个任务就是这样),抖动要用 Full Jitter 这一类。

试几次:重试会层层相乘

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

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

层默认
SDK 自带重试anthropic Python SDK 1.11.0:DEFAULT_MAX_RETRIES = 2(_constants.py),重试 408、409、429、5xx 和连接错误;服务端返回 x-should-retry 头时听它的
你的 harness自己写的 for 循环,比如一共试 4 次
模型自己"再来一次"工具回了 is_error,模型可能换个参数或原样再调

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

自己包一层重试时,记得把 SDK 的 max_retries 调成 0,或者干脆不在外面再包。

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

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

两份主要参考:

  • Stripe 的 Idempotent requests 文档 :保存第一次请求的状态码和响应体,“regardless of whether it succeeds or fails”,之后同一个键返回同样的结果,包括 500;建议用 V4 UUID,键最长 255 个字符;键至少 24 小时后可以被清理,清理后再用同一个键会当成新请求;会比较新请求和原请求的参数,不一致就报错;参数校验失败、或者和另一个正在执行的请求冲突时不保存结果,可以重试;所有 POST 都接受幂等键,GET 和 DELETE 本来就幂等,不用带。
  • IETF httpapi 工作组的草案 The Idempotency-Key HTTP Header Field ,最新版 draft-07(2025-10-15),datatracker 上的状态是已过期(2026-04-18),不是 RFC。

草案对服务端的建议(都是 SHOULD):

情况草案建议
第一次见到这个键正常处理
同键、同请求体,原请求已完成返回上一次的结果,成功或错误
同键、不同请求体422 Unprocessable Content
同键,原请求还在处理409 Conflict
需要键却没带400

还有几个细节:键 MUST 唯一,MUST NOT 用在请求体不同的另一个请求上;推荐用 UUID;服务端可以用请求体算一个"指纹",和键一起判断是不是同一个请求;键的过期策略由服务端定义并公布。草案里键的值是结构化字段的 String,带引号(Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"),Stripe 的示例不带引号。各家实现细节不同,以对方文档为准。

原子性:记键和写数据要在同一个事务里

最常见的错误写法是"查键 → 插报告 → 登记键"三步、两次提交。插完报告、登记键之前进程崩了,库里就有一份"没登记的报告";客户端收到 500 去重试,查键查不到,又插一份。

正确的写法(本地 bug_tracker.py 的 key_atomic):

  1. 开一个写事务,查键。同键不同指纹 → 422;已完成 → 重放;正在处理且没超过租约 → 409;否则写一条 in_progress 占位(带时间戳和 fencing token:每次占键或接管都加 1),提交。
  2. 处理请求(比如先重放一遍复现步骤)。
  3. 再开一个事务,插报告和把键标成 done 放在同一个事务里,并且 UPDATE 带上条件 status='in_progress' AND fence=<自己的 token>。影响行数不是 1,说明占位已经被别人接管,整个事务回滚,报告一起撤销;是 1 才提交。

“要么都有,要么都没有"只是单个请求内部的保证:报告和"键已完成"不会只留一半。它管不了两个请求之间的事。

处理中抛异常时删掉占位,重试可以立刻接手;进程被 kill -9 时走不到这一步,占位留在库里,等租约过期后才允许新请求接管。接管的前提是原请求真的死了,而服务端只能靠时间猜:一个卡在 GC 停顿或者处理特别慢的请求,过了租约也还活着。这时新请求接管、写完报告,慢请求醒过来再写,就是第二份。挡住它的是第 3 步的 fencing token:接管时 token 变成 2,慢请求拿着 1 去更新,影响 0 行,回滚,然后重放接管者的结果(接管者还没完成就回 409)。

所以租约要大于"客户端超时 + 最长处理时间”(本地是 5 秒,harness 超时 2 秒),尽量别误判;误判了还有 fencing 兜底。租约太短,活着的慢请求会被接管,处理时间总比租约长时甚至会互相接管、谁都写不进去;太长,进程真死了以后用户要多等一阵 409。

如果副作用不在你自己的数据库里(比如调第三方发通知),就没法和"记键"放进同一个事务。常见做法是把幂等键继续传给下游(前提是下游也支持),或者先在本地事务里写一条"待发送"记录,再由单独的流程按记录投递(常叫 outbox 模式),投递本身也要能去重。

Codex 里的一个真实例子

Codex TUI 里"用掉一次额度重置"会扣掉一个重置额度,是一个有副作用的操作。它的处理正好是上面的模式:

Err(_) => {
    self.pending_rate_limit_reset_request_id = None;
    self.pending_rate_limit_reset_idempotency_key = Some(idempotency_key.clone());
    // ... "Couldn't reset usage. Please try again." ...
            actions: vec![Box::new(move |tx| {
                tx.send(AppEvent::ConsumeRateLimitResetCredit {
                    idempotency_key: idempotency_key.clone(),
                    credit_id: credit_id.clone(),
                });
            })],

Err(_) 是"请求失败,不管什么原因"。失败后把键存回 pending_...,“Try again"按钮的回调里发的还是 idempotency_key.clone(),也就是原来那个键。

对应的测试 rate_limit_reset_retry_reuses_idempotency_key( tui/src/chatwidget/tests/usage.rs:659-687 ):用 Err("response lost") 模拟响应丢失,按回车点"Try again”,断言发出的事件里键还是 "stable-redeem-id"。上面一个测试( :635-645 )还断言了:用错误的键启动返回 None;同一个键启动一次之后再启动也返回 None,界面层不会因为连按两次而发出两个请求。

顺带一提,anthropic Python SDK 1.11.0 的基类里也有"一次逻辑请求一个键、重试复用"的机制:非 GET 请求在重试循环开始前生成 stainless-python-retry-<uuid>,注释是 “ensure the idempotency key is reused between requests”(_base_client.py:1138-1140)。但 _idempotency_header 在 BaseClient.__init__ 里默认是 None(_base_client.py:435),Anthropic 客户端子类没有覆盖它,这个键不会作为请求头发出去。

Agent 特有的坑:重试有两层

普通服务的重试只有一层。Agent 里有两层:

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

应对办法:

  • 工具结果写清楚"结果未知,报告可能已经创建,重新提交前先查询",而不是"提交失败"。后者等于在劝模型再提交一次。
  • 需要跨模型层去重时,用业务内容推导键:同一次评测、同一模块、规范化后的标题,取哈希。业务键也有边界:模型第二次把标题改写了,就认不出来,最后只能靠评测侧去重,这是第 6 周 Judge 的活。

Codex 在这件事上的取舍:采样重试从历史重新组装请求( core/src/session/turn.rs:1656-1662 ,第 3 周讲过),已经跑完的工具调用和输出都在历史里,不会因为采样重试再执行一遍。重试模型调用和重试工具调用是两回事:前者对外部世界没有副作用(除了花钱),后者可能有。工具执行这一侧,Codex 的"重试"是沙箱拒绝后换一个沙箱策略再跑一次,要过审批,见「Agent 要执行 rm -rf,谁来拦?」那一章。

MCP 也给了一个相关的字段:工具 annotations 里的 idempotentHint, 2026-07-28 版 schema 的定义是 “If true, calling the tool repeatedly with the same arguments will have no additional effect on its environment.",默认 false,只在 readOnlyHint == false 时有意义。它只是提示:schema 注释说 annotations 都是 hints,“Clients should never make tool use decisions based on ToolAnnotations received from untrusted servers."。所以不能因为一个不可信的 server 标了 idempotentHint: true 就放心自动重试。

动手实验:在服务端注入故障,把重复抓出来

代码在学习目录的 week05_长任务可靠性/code/retry_idempotency/,只用标准库(http.server、sqlite3、http.client),不调任何模型 API:

文件内容
backoff.py五种退避策略;逐行翻译的 Codex backoff() 和 anthropic SDK 的等待计算
herd_sim.py移植的惊群模拟器(上面那张表)
bug_tracker.pyBug 报告服务:naive / key_split / key_atomic 三种写法,三种故障注入
harness.py测试 Agent 一侧:带重试的提交、四种键来源、把 tool_use 变成 tool_result
demo_matrix.py5 种故障 × 5 种写法,每格数库里最后有几份报告
test_backoff.py、test_idempotency.py37 个测试

服务端的三种故障都是计数器,大于 0 就触发一次:

  • lose_response_after_commit:已经提交,然后一个字节都不回,直接断开连接。客户端看到的是连接被重置,和"响应在路上丢了"一样。
  • crash_mid_request:处理到一半抛异常,返回 500。崩在哪一步取决于写法:naive 崩在报告已提交之后,key_split 崩在报告已提交、键还没登记时,key_atomic 崩在占键之后、写报告之前。
  • slow_ms:处理耗时,放在查键 / 占键之后、写报告之前,制造"同键请求还在处理”。

key_atomic 的核心几行:

db.execute("BEGIN IMMEDIATE")                       # 拿写锁,"查键 + 占键"不会被并发请求插队
row = db.execute("SELECT fingerprint, status, locked_at, response_code, response_body, fence "
                 "FROM idempotency_keys WHERE key=?", (key,)).fetchone()
if row and row[0] != fp:
    db.execute("ROLLBACK")
    return 422, {"error": "Idempotency-Key 已用于另一份请求体"}, False
if row and row[1] == "done":
    db.execute("ROLLBACK")
    return row[3], json.loads(row[4]), True         # 重放第一次的响应
if row and now - row[2] < self.lease_s:
    db.execute("ROLLBACK")
    return 409, {"error": "同一个 Idempotency-Key 的请求还在处理"}, False
fence = (row[5] if row else 0) + 1                  # 接管过期占位时 token 加 1,旧持有者的 token 作废
db.execute("INSERT OR REPLACE INTO idempotency_keys(key, fingerprint, status, locked_at, fence) "
           "VALUES (?,?,?,?,?)", (key, fp, "in_progress", now, fence))
db.execute("COMMIT")
# ... 处理(故障注入点)...
db.execute("BEGIN IMMEDIATE")
body = self._insert_report(payload, db)
cur = db.execute("UPDATE idempotency_keys SET status='done', response_code=201, response_body=? "
                 "WHERE key=? AND status='in_progress' AND fence=?", (json.dumps(body), key, fence))
if cur.rowcount != 1:                               # 占位已被接管:报告跟着回滚,不留第二份
    db.execute("ROLLBACK")
    return self._after_fenced_out(db, key)          # 接管者已完成就重放它的结果,否则 409
db.execute("COMMIT")                                # 报告和"键已完成"一起提交(单个请求内部的保证)

BEGIN IMMEDIATE 是 SQLite 一开始就拿写锁的事务,两个同键请求不会同时通过"查键”。占键单独提交,是为了让并发的同键请求能看到 in_progress 并收到 409。

客户端(harness.py)对连接错误、409、429、500/502/503/504 重试,其他 4xx 直接失败;退避用 Full Jitter(base 20ms、cap 200ms)。四种键来源:不带键、每次尝试新 UUID(错误示范)、run_id:tool_use_id、业务内容哈希。

故障矩阵

$ python3 demo_matrix.py
每格 = 服务端最终的报告份数(期望 1);合成场景,本机真实 HTTP + SQLite

服务端写法 / 键来源           响应丢失后重试    服务端中途崩溃      并发重复执行    模型换 id 重提  模型改写标题重提
naive / none                               2                 2                 2                 2                 2
key_atomic / per_attempt                   2                 1                 2                 2                 2
key_split / tool_call                      1                 2                 2                 2                 2
key_atomic / tool_call                     1                 1                 1                 2                 2
key_atomic / business                      1                 1                 1                 1                 2

每一行都有挡不住的那一列:

  • 每次尝试新 UUID和不带键差不多,只有"崩在写报告之前"那一列碰巧是 1(第一次什么都没写)。
  • key_split 挡住了响应丢失(键在响应发出前就登记了),但挡不住两次提交之间的崩溃,也挡不住并发:两个请求都在对方登记之前查键,各插一份,第二个登记键时撞主键返回 500。
  • key_atomic + tool_call 键挡住了 harness 层的三种故障,挡不住模型换 id 重提。
  • 业务键挡住了换 id,挡不住模型改写标题。

演示 2 里"跑完 5 种场景 × 5 种写法"算出的表和这张一致。这 25 种组合里没有"慢请求被接管"的场景,fencing 挡下旧请求的分支只由上面的 test_slow_request_taken_over_cannot_write_second_report 覆盖,演示没有演到它。

测试

$ python3.13 -m pytest -q -p no:cacheprovider
.....................................                                    [100%]
37 passed in 3.34s

(Python 3.13.7,pytest 9.1.1;连跑 6 次都是 37 passed。)其中 test_idempotency.py 的 16 个测试,几个关键的:

def test_naive_server_duplicates_report_after_lost_response(tracker):
    """测试本身要能变红:同样的场景放到不认幂等键的服务上,必须看到 2 份。"""
    url, svc = tracker("naive")
    svc.faults.lose_response_after_commit = 1
    out = submit(url, "none")
    assert [a.outcome for a in out.attempts] == ["transport", "201"]
    assert out.ok and svc.count() == 2


def test_lost_response_retry_with_stable_key_replays_first_result(tracker):
    url, svc = tracker("key_atomic")
    svc.faults.lose_response_after_commit = 1
    out = submit(url, "tool_call")
    assert [a.outcome for a in out.attempts] == ["transport", "201"]
    assert out.attempts[1].replay is True                     # 第二次拿到的是第一次的结果
    assert out.attempts[0].key == out.attempts[1].key == "run-042:toolu_01"
    assert svc.count() == 1 and out.body == {"report_id": 1}

第一个测试断言的是错误实现上出现了 2 份。它的作用是证明故障注入真的生效:如果响应根本没丢,客户端只发一次请求,正确和错误的实现都只有 1 份,第二个测试是绿的,但什么也没证明。先让测试在已知有 Bug 的实现上变红,是在验证测试本身。

第二个测试除了数行数,还断言了两次尝试用的是同一个键、第二次拿到的是重放的结果、返回的 report_id 就是库里那一份。只数行数,会漏掉"服务端没插第二份,但给客户端返回了一个不存在的 id"这种 Bug。

其他测试覆盖:两次提交之间崩溃(key_split 出现 2 份,key_atomic 只有 1 份);同键并发(key_atomic 只有 1 份,过程中至少出现一次 409,两个 worker 最后拿到同一个 report_id);同键不同请求体返回 422;缺字段的 400 不重试;手动塞一条"进程死掉留下的占位",租约内 409、过期后被接管(fencing token 从 1 变成 2);租约必须长于 harness 的客户端超时;慢请求被接管(把租约故意设成 0.5 秒、第一个请求处理 0.8 秒,关掉 fencing 时出现 2 份,打开时 1 份,两个 worker 拿到同一个 report_id);以及模型层重提的三种情况。

和第 1 周连起来:并发测试依赖时序(300ms 的处理时间、真实的退避等待),这类测试最容易 flaky。上面连跑 6 次都通过,只能说明它不常挂:0/6 次失败时,失败率的 95% 单侧上界是 \(1-0.05^{1/6}\approx 39\%\)(n 这么小时 rule of three 的 3/n 不准,要用精确式)。要更有信心,得在 CI 里多跑,或者把并发换成可控的时钟和显式的交错(演示 2 的 JS 版就是这么做的:先让两个请求都"查完键",再让它们依次"写完")。

测开视角:重试相关的用例清单

用例怎么注入断言什么
响应丢失服务端提交后断开连接库里 1 份;两次尝试同一个键;第二次是重放
两次提交之间崩溃服务端在写数据后、登记键前抛异常库里 1 份(错误写法是 2 份)
同键并发服务端处理变慢,两个 worker 同时提交库里 1 份;后到的先收到 409,最后拿到同一个 id
同键不同请求体直接用同一个键发两份不同的请求422,库里 1 份
进程死掉留下占位手动写一条过期 / 未过期的 in_progress租约内 409,过期后被接管
慢请求被接管租约设得比处理时间短,两个 worker 同键提交库里 1 份(不校验 fencing token 时是 2 份);被挡下的慢请求重放接管者的结果
4xx 不重试缺字段只发 1 次请求
模型层重提harness 放弃后用新的 tool_use id 再提交tool_result 写"结果未知";业务键能去重
退避的边界固定随机种子每次等待都在公式给的区间里;有 cap 的不超过 cap(Codex 幅度乘在封顶之后,最多到 1.1 × cap)
重试放大计数 mock 服务收到的请求一次逻辑调用的请求数 ≤ 设计上限(别忘了 SDK 自带的重试)

常见错误说法

  • “超时了就是失败了,重试一下就好”:响应可能只是丢了,服务端已经做完。超时的结果是"未知",不是"失败"。
  • “加了指数退避就不会惊群”:没有抖动时,同一时刻失败的客户端会在同一时刻重试。合成实验里无抖动的调用数是 Full Jitter 的 2.3 倍(1847 / 796)。
  • “每次请求都带一个新的 UUID 当幂等键”:键要标识一次逻辑操作,重试必须复用。
  • “Idempotency-Key 是 HTTP 标准”:IETF 那份是草案,draft-07 已过期,不是 RFC;Stripe 等各家实现的细节不同。
  • “先插数据,再记幂等键就行”:两步之间崩溃会留下没登记的数据,重试又插一份;并发时两个请求也会都通过查键。
  • “重试越多越可靠”:每一层都重试会相乘,服务端过载时多出来的请求会拖慢恢复。
  • “MCP 工具标了 idempotentHint 就可以放心自动重试”:annotations 只是提示,来自不可信 server 时不能据此做决定。

下一讲:断点续跑。长任务跑到一半进程被杀,从哪里接着跑,checkpoint 存什么、存多细。