第 26 章
第 5 周:环境坏了,测试 Agent 会把它报成 Bug 吗?
给 Agent 做故障注入:按层列故障清单,用第 3 周的假模型服务器注入卡住的流、首字节慢和流里的 error 事件,测出第 3 周 loop 会挂 10 分钟、遇到 error 事件直接崩溃,并把第 3 周记下却没修的「重试放大到 9 个请求」钉成测试、改掉;用固定种子做随机注入,对比独立故障和扎堆故障;用 Playwright 的 page.route 弄坏被测应用的网络,看测试 Agent 会不会误报 Bug;toxiproxy 只做介绍。一段讲解视频,一个故障注入实验台和独立/扎堆故障模拟器。
测试 Agent 的结论有两种错法:被测应用真有 Bug,它没找到;被测环境出了问题(网络抖一下、后端 503、模型 API 卡住),它把这当成产品 Bug 报上来。后一种在日常跑分时很难发现,因为环境故障本来就少,每次表现还不一样。“不好复现,就把日志发给供应商"是被动的做法。主动的做法是故障注入:自己把环境弄坏,想坏几次就坏几次,然后断言 Agent 的反应。
第 3 周「不调真实模型,怎么测一个 Agent?」那一章已经用假模型服务器测过断流、缺 message_stop、429 和畸形 JSON。这一章接着做三件事:补上时间类故障(卡住、首字节慢)和流里的 error 事件,其中首字节慢引起的重试放大第 3 周已经实测记下、但没有修;用固定种子做随机注入;再换到 BugHunt 的另一头,用 Playwright 把被测应用的网络弄坏。所有实验都在本地真实运行,数据是合成的,不调用付费模型 API。
讲解视频
互动演示
两个演示,都不发真实请求。第一个是故障注入实验台:选故障(中途断开、缺 message_stop、畸形 JSON、卡住、首字节慢、流里的 error 事件、HTTP 529、HTTP 400)、选"只坏一次"还是"一直坏”、选第 3 周或第 5 周的 loop,再调 read 超时和两层重试次数,看它发了几个请求、最坏等多久、结局是什么。行为规则按本地实测的 SDK 1.11.0 推演。第二个对比独立故障和扎堆故障:调故障率、扎堆程度、重试次数和调用次数,看独立公式、精确值和带种子的模拟结果差多少。页面底部有自动判分的练习。
注入之后,什么样的结局算对
先把"正确行为"写清楚,否则注入了故障也不知道该断言什么。
| 结局 | 算不算对 |
|---|---|
重试后恢复,任务 done | 对(故障是暂时的) |
重试用完,明确上报 env_error,附上证据 | 对(故障一直在) |
| 抛出没分类的异常,整个进程崩掉 | 错 |
| 一直等,挂住不动 | 错 |
把半截输出当成 done | 错 |
| 把环境故障报成产品 Bug | 错,BugHunt 里代价最大的一种 |
再按层列故障清单。每一层用不同的工具注入:
| 层 | 故障 | 注入工具 | 本章 |
|---|---|---|---|
| TCP / 网络 | 延迟、连接被重置、只传一部分数据就断、限速 | toxiproxy | 只介绍,本机未安装,未运行 |
| HTTP | 429、529、503、400;响应头迟迟不来 | 假模型服务器 | 429 第 3 周已测;本章测 529、400、首字节慢 |
| SSE 流 | 中途断开、缺 message_stop、畸形 JSON、流里的 error 事件、卡住不动 | 假模型服务器 | 前三种第 3 周已测;本章测后两种 |
| 被测应用 | API 连接被重置、503、响应太慢、只坏一次 | Playwright page.route | 本章实际运行 |
代码在本地 week05_长任务可靠性/code/fault_injection/。假模型服务器从 week03_Codex源码/code/mock_model_server/ 复制过来,原文件不动,只加了两个参数:SSE(stall_after=n) 发完 n 个事件后既不发数据也不断开连接;SSE(headers_delay_s=x) 先等 x 秒再发响应头。
第 3 周没测到(或没修)的三种故障
被测对象先用第 3 周的 loop 原样跑(测试里用 importlib 只读加载原文件),出了问题再改出第 5 周的版本。三种故障各对应一个问题,其中第二个第 3 周已经发现、只是没修。
卡住的流:默认要等 10 分钟
流发了 3 个事件就不动了,连接也不断。SDK 1.11.0 的默认超时是 httpx2.Timeout(timeout=10 * 60, connect=5.0)(anthropic/_constants.py:9),也就是 read 超时 600 秒。read 超时管的是"两次收到数据之间最多等多久",所以卡住的流要等满 600 秒才会抛 httpx2.ReadTimeout。这时响应头早就到了,SDK 也不会替你重试。
def test_stalled_stream_with_sdk_default_timeout_hangs(server):
"""反例:不设 read 超时,SDK 1.11.0 默认等 600 秒。2 秒后这个调用还挂着。"""
server.enqueue(SSE(DONE, stall_after=3))
default_client = anthropic.Anthropic(base_url=server.base_url, api_key="test-key")
worker = threading.Thread(target=run_agent, args=(default_client, TASK), kwargs={"impls": IMPLS}, daemon=True)
worker.start()
worker.join(timeout=2.0)
assert worker.is_alive() # 还在等;退出 fixture 时服务器放行,线程随之结束
assert len(server.requests) == 1
Codex 对应的配置是 stream_idle_timeout,默认 300,000 毫秒(第 3 周讲过)。实现是给"等下一个事件"包一层超时,
codex-rs/codex-api/src/sse/responses.rs:535-542
:
loop {
let start = Instant::now();
let response = tokio::select! {
biased;
_ = tx_event.closed() => return,
response = timeout(idle_timeout, stream.next()) => response,
};
loop每读一个事件转一圈,timeout(idle_timeout, stream.next())的意思是"等下一个事件,最多等idle_timeout",每一圈重新计时。tokio::select!同时等两件事,谁先发生走谁:消费方已经不要结果了(tx_event.closed()),就直接退出;否则等下一个事件。biased;表示按书写顺序优先检查前一个。- 超时后进入
Err(_)分支,报idle timeout waiting for SSE(同文件:565-570),走流层重试。
我们的修法是给 SDK 客户端显式设 read 超时,效果和 Codex 的空闲超时一样。修好之后,测试里 read 超时 0.3 秒,卡住的流 0.3 秒后判超时、整轮重发、第二次成功,重发的请求体和第一次逐字相同。
首字节慢:一次调用变成 9 个请求
响应头迟迟不来。这时客户端还没拿到响应,read 超时触发后,SDK 把 httpx2.TimeoutException 包成 APITimeoutError,按 max_retries=2 自己重试。第 3 周的 loop 在流层捕获了 anthropic.APIConnectionError,而 APITimeoutError 正是它的子类,所以流层又重试 2 次:
def test_week03_loop_amplifies_slow_first_byte_to_nine_requests(server, client):
server.enqueue(*[SSE(DONE, headers_delay_s=1.0)] * 9)
log = []
with pytest.raises(anthropic.APITimeoutError):
week03.run_agent(client, TASK, impls=IMPLS, log=log, **FAST)
assert len(server.requests) == 9
assert [r.headers["x-stainless-retry-count"] for r in server.requests] == ["0", "1", "2"] * 3
assert len(log) == 3
请求头 x-stainless-retry-count 是 SDK 每次请求带上的重试序号,0,1,2 重复三遍,说明 SDK 那一层被完整地跑了三轮。read 超时取默认的 600 秒时,只有 SDK 一层重试,最坏也要 3 × 600 秒,也就是 30 分钟;两层相乘是 9 × 600 秒,超过 90 分钟。同一个错误在几层都被重试、次数相乘,道理在「超时了,再发一次安全吗?」那一章讲过,这里是在自己的代码里把它测出来了。「不调真实模型,怎么测一个 Agent?」那一章已经实测到 9 个连接并记了下来,但没有消掉;这一章用 x-stainless-retry-count 把它钉成测试,并改掉。
Codex guardian 子系统的连接池里有一句注释说的正是这个阶段,
codex-rs/ext/guardian-v2/src/async_scorer/sampler/connection_pool.rs:479-482
:
// The SSE idle timeout starts after headers arrive. Bound that wait too.
tokio::time::timeout(
self.pool.config.provider.info().stream_idle_timeout(),
client.stream_request(
流的空闲超时从响应头到达才开始算,等响应头的这一段要单独用 tokio::time::timeout 兜住。超时要按阶段分:连接、等响应头、两个事件之间、整个任务。
修法是按"出错时处在哪个阶段"分层:响应头已经到了、读流时出的错才走流层重试;拿到响应之前的错交给 SDK 那一层,不再叠加。第 5 周的 stream_once:
class StreamFault(Exception):
"""响应头已经到了,读流时出的错。只有这一类走流层重试。"""
def stream_once(client: anthropic.Anthropic, messages: list) -> anthropic.types.Message:
# 进入 with 时发请求;SDK 在这里做 HTTP 层重试(429 / 5xx / 连接失败 / 首字节超时)
with client.messages.stream(model=MODEL, max_tokens=64000, tools=TOOLS, messages=messages) as stream:
try:
saw_stop = False
for event in stream:
if event.type == "message_stop":
saw_stop = True
if not saw_stop:
raise StreamIncomplete("stream closed before message_stop")
return stream.get_final_message()
except (StreamIncomplete, httpx2.TransportError, anthropic.APIStatusError, ValueError) as e:
# 断开 / 卡住超时(ReadTimeout)/ 流里的 error 事件 / 畸形 JSON
raise StreamFault(f"{type(e).__name__}: {e}") from e
call_model 只捕获 StreamFault。同一个故障打在第 5 周的 loop 上,恰好 3 个请求(x-stainless-retry-count 为 0,1,2),结局是 env_error: APITimeoutError。
注意上限没变:如果每一轮流层重试里又先碰上首字节超时,一次调用最坏仍是 (SDK 2+1) × (流层 2+1) = 9 个请求。测试 test_mixed_faults_still_reach_nine_requests 每一轮先让响应头迟到两次、第三次拿到响应头后流又卡住,连续三轮,实测正好 9 个请求,结局是 env_error: ReadTimeout。修法消除的是"同一个错误被两层各重试一遍";要把总量也压住,就再加一个整次调用的请求数或时间预算。
「超时了,再发一次安全吗?」那一章建议,自己包一层重试时把 SDK 的 max_retries 调成 0。这里保留两层,是因为两层管的错误不相交:SDK 只管拿到响应之前,流层只管读流时。代价就是上面这个混合故障的上限。
流里的 error 事件:第 3 周的 loop 直接崩
官方文档
说,API 偶尔会在事件流里发错误,例如高峰期的 overloaded_error,它在非流式场景下对应 HTTP 529:
event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}
这时 HTTP 状态码已经是 200 了。SDK 1.11.0 读到 error 事件时,用当前响应构造异常(anthropic/_streaming.py:132-145),而当前响应的状态码是 200,所以抛的是笼统的 APIStatusError,不是 OverloadedError。它不在第 3 周 loop 的重试列表里,整个 agent 崩掉,只发了 1 个请求。第 5 周的版本把它算作流层故障,重发一次就好。
文档的错误页把 529 列为暂时过载,SDK 也会自动重试 HTTP 529,所以我们选择对流里的 overloaded_error 做有上限的重试。第 3 周读过,Codex 对流里的 server_is_overloaded 选择直接终止。两种选择都说得通,关键是用测试把选择钉住。
顺带发现一个容易写错的地方:SDK 1.11.0 默认客户端(anthropic.Anthropic)收到 HTTP 529 抛的是 OverloadedError,它直接继承 APIStatusError,不是 InternalServerError 的子类(anthropic/_exceptions.py:171、anthropic/_client.py:579-580);其他 5xx(含 503、504)才是 InternalServerError。所以只写 except anthropic.InternalServerError 兜"服务端错误",会漏掉 529。ServiceUnavailableError(503)、DeadlineExceededError(504)只在 Bedrock / Vertex 客户端里使用。本机用假服务器、max_retries=0 实测:500、503、504 都是 InternalServerError,529 是 OverloadedError。
重试用完以后:env_error,不是 done,也不是崩溃
故障一直不消失时,第 5 周的 run_agent 返回 env_error: 异常名,messages 里没有半截的 assistant 消息。反过来,400 说明请求本身写错了,是 harness 自己的 bug,不能当成环境问题吞掉,要照样抛出:
def test_persistent_529_is_reported_as_env_error_not_done(server, client):
server.enqueue(*[HTTPError(529, "overloaded_error", "Overloaded")] * 3)
status, messages = run_agent(client, TASK, impls=IMPLS, **FAST)
assert status == "env_error: OverloadedError"
assert len(server.requests) == 3
assert messages == [{"role": "user", "content": TASK}] # 历史里没有半截的 assistant 消息
def test_400_is_not_swallowed_as_env_error(server, client):
server.enqueue(HTTPError(400, "invalid_request_error", "messages: bad"))
with pytest.raises(anthropic.BadRequestError):
run_agent(client, TASK, impls=IMPLS, **FAST)
assert len(server.requests) == 1
故障目录里的五种流层故障(断开、缺 message_stop、畸形 JSON、卡住、流里的 error 事件)还各有一个参数化测试:连续 3 次都坏,断言恰好 3 个请求、结局是 env_error。
Codex 怎么造"时间类"故障:gate
我们的 stall_after 用一个 threading.Event 让服务器"卡住、直到测试结束才放行"。Codex 的测试服务器做得更细:每一块 SSE 都可以带一个 gate,测试代码发信号之前,这一块不会发出去。
codex-rs/core/tests/common/streaming_sse.rs:13-18
和
:147-151
:
/// Streaming SSE chunk payload gated by a per-chunk signal.
#[derive(Debug)]
pub struct StreamingSseChunk {
pub gate: Option<oneshot::Receiver<()>>,
pub body: String,
}
// ...
for chunk in chunks {
if let Some(gate) = chunk.gate
&& gate.await.is_err() {
return;
}
oneshot::Receiver<()>是一个只能收一次信号的通道,测试代码手里拿着对应的发送端。- 发送每一块之前先
gate.await:等到测试发信号再发。发送端被丢掉(is_err())就直接结束这个连接。 - 同文件的自测
gated_chunks_wait_for_signal_and_preserve_order(:473-523)断言:放行之前 200 毫秒内读不到任何数据;放行第一块后只能读到第一块。
这种写法能让"卡在第几块、卡多久、什么时候恢复"全由测试决定,比用 sleep 造慢流更确定,也不会让测试真的等很久。
本机输出
$ .venv/bin/python -m pytest -v -p no:cacheprovider
============================= test session starts ==============================
platform darwin -- Python 3.13.7, pytest-9.1.1, pluggy-1.6.0
plugins: anyio-4.15.1
collecting ... collected 19 items
bughunt_web/test_bughunt.py::test_careful_agent_matches_ground_truth PASSED [ 5%]
bughunt_web/test_bughunt.py::test_naive_agent_reports_environment_faults_as_bugs PASSED [ 10%]
bughunt_web/test_bughunt.py::test_env_error_carries_network_evidence PASSED [ 15%]
test_model_faults.py::test_stalled_stream_times_out_then_retries PASSED [ 21%]
test_model_faults.py::test_stalled_stream_with_sdk_default_timeout_hangs PASSED [ 26%]
test_model_faults.py::test_slow_first_byte_is_retried_only_by_sdk PASSED [ 31%]
test_model_faults.py::test_mixed_faults_still_reach_nine_requests PASSED [ 36%]
test_model_faults.py::test_week03_loop_amplifies_slow_first_byte_to_nine_requests PASSED [ 42%]
test_model_faults.py::test_midstream_overloaded_event_is_retried PASSED [ 47%]
test_model_faults.py::test_week03_loop_crashes_on_midstream_overloaded_event PASSED [ 52%]
test_model_faults.py::test_persistent_529_is_reported_as_env_error_not_done PASSED [ 57%]
test_model_faults.py::test_400_is_not_swallowed_as_env_error PASSED [ 63%]
test_model_faults.py::test_every_fault_kind_three_times_in_a_row_gives_env_error[cut] PASSED [ 68%]
test_model_faults.py::test_every_fault_kind_three_times_in_a_row_gives_env_error[malformed] PASSED [ 73%]
test_model_faults.py::test_every_fault_kind_three_times_in_a_row_gives_env_error[no_stop] PASSED [ 78%]
test_model_faults.py::test_every_fault_kind_three_times_in_a_row_gives_env_error[overloaded] PASSED [ 84%]
test_model_faults.py::test_every_fault_kind_three_times_in_a_row_gives_env_error[stall] PASSED [ 89%]
test_model_faults.py::test_seeded_random_plan_matches_oracle[7] PASSED [ 94%]
test_model_faults.py::test_seeded_random_plan_matches_oracle[2026] PASSED [100%]
============================= 19 passed in 35.58s ==============================
环境:Python 3.13.7,anthropic 1.11.0,pytest 9.1.1,Playwright 1.63.0。
随机注入:固定种子,结果要对得上预言
逐个故障测完之后,还要看故障随机出现时整体可靠性有多少。两条规矩:
- 随机数用固定种子,并记录每个请求注入了什么。失败了能原样复现,换台机器结果也一样。
- 先由计划推出预言,再真的跑一遍核对。计划决定了第几个请求坏、坏成什么样,按 loop 的规则就能推出"这个任务该成功还是
env_error、该发几个请求"。实际结果和预言不一致,说明 loop 的行为和你以为的不一样。
设每个请求出故障的概率是 \(p\),每次模型调用最多尝试 \(r+1\) 次,一个任务要 \(n\) 次调用。如果各请求的故障相互独立:
$$ P(\text{任务成功}) = \bigl(1 - p^{\,r+1}\bigr)^{n} $$\(p = 0.3,\ r = 2,\ n = 2\) 时是 \(0.973^2 \approx 0.9467\)。
真实故障往往扎堆:服务过载、网络抖动都会持续一段时间。用两状态马尔可夫链建模:上一个请求坏了,这一个以概率 \(q\) 继续坏;上一个好,这一个以概率 \(p_0\) 变坏,取
$$ p_0 = \frac{p(1-q)}{1-p} $$这样长期平均故障率仍是 \(p\),只是坏的请求挨在一起(\(q = p\) 时退化为独立)。\(q = 0.8\) 时,第一次调用三次全坏的概率是 \(p q^2 = 0.192\),独立时只有 \(p^3 = 0.027\)。正相关让重试一起失败,所以真实成功率低于独立公式;枚举全部 \(2^6\) 种坏/好序列,精确值是 0.7637。
experiment_random_faults.py 每种模式跑 400 个任务,每个任务都真的打到假服务器上,故障类型从五种流层故障里随机选(合成数据,种子 20261001)。下面是一次运行的输出,除耗时外都由种子决定;耗时随机器变化:
p = 0.3, q = 0.8, 每次调用最多 3 次尝试,每个任务 2 次调用,N = 400,种子 20261001
独立假设下的公式 (1 - p^3)^2 = 0.9467
[independent] 精确值 0.9467
实测成功 386/400 = 0.9650,Wilson 95% [0.9421, 0.9790]
请求 1097 个,其中注入故障 322 个(0.294):{'cut': 75, 'malformed': 66, 'no_stop': 69, 'overloaded': 66, 'stall': 46}
实际与预言不一致:0;耗时 223.1 秒
[bursty] 精确值 0.7637
实测成功 310/400 = 0.7750,Wilson 95% [0.7316, 0.8132]
请求 989 个,其中注入故障 352 个(0.356):{'cut': 73, 'malformed': 72, 'no_stop': 79, 'overloaded': 70, 'stall': 58}
实际与预言不一致:0;耗时 221.9 秒
几点解读:
- 800 个任务的实际结果和请求数全部与预言一致。这条断言比成功率本身更重要,它说明 loop 对每一种故障的反应都符合设计。
- 两个 Wilson 区间(「30 次挂了 2 次,失败率在什么范围?」那一章)都盖住了各自的精确值。
- 扎堆模式下,实际发出的请求里故障占 35.6%,高于设定的 30%。原因是坏了才会重试,而重试的请求正好落在坏的时段里。
- 这个结论依赖"重试紧接着发生"。如果退避等待比一段故障持续得还久,重试会落到好的时段,相关性就弱了。用独立公式估可靠性,和「跑了 300 次,就是 300 个样本吗?」那一章说的是同一件事:存在正相关时,按独立假设算出来的结果偏乐观。
注入时也要注意让故障能扎堆:toxiproxy 开一个 toxic、过一段时间再删掉,就是一段连续的故障;只用"每个请求独立掷骰子"的注入,测不出这种情况。
BugHunt:用 Playwright 把被测应用的网络弄坏
前面弄坏的是 Agent 依赖的模型 API。BugHunt 里还有另一头:测试 Agent 操作的被测 Web 应用。这一头的环境故障更容易被误报成 Bug。
被测应用是一个合成的购物车页面(bughunt_web/app.py,只用标准库):前端从 /api/cart 取数据,自己算合计,正确值是 487.00;?v=buggy 版本注入了一个产品 Bug,合计忘了乘数量,显示 288.00。前端请求带 1 秒超时,失败时页面显示"加载失败"。
故障用 Playwright 的
page.route
注入,被测应用的代码一行不改:
async def _slow(route: Route) -> None:
await asyncio.sleep(2.0) # 比前端的 1 秒超时长
try:
await route.continue_()
except Exception: # 前端已经放弃这个请求了
pass
async def inject(page: Page, fault: str) -> None:
if fault == "reset":
await page.route(API, lambda route: route.abort("connectionreset"))
elif fault == "503":
await page.route(API, lambda route: route.fulfill(status=503, body="Service Unavailable"))
elif fault == "slow":
await page.route(API, _slow)
elif fault == "reset_once":
await page.route(API, lambda route: route.abort("connectionreset"), times=1) # 只坏第一次
route.abort(error_code)让请求以网络错误失败,错误码可以是connectionreset、timedout、internetdisconnected等,默认failed。route.fulfill(status=503, ...)不访问后端,直接回一个伪造的响应。route.continue_()把请求原样放行,先sleep再放行就是一个慢接口。page.route(..., times=1)只拦截第一次匹配的请求,用来造"只坏一次"的暂时故障。
两个"测试 Agent"替身都是规则写死的,不调模型,目的是让实验确定、可复现。真实的 BugHunt Agent 由模型做同样的判断,判断依据就是 observe() 收集的网络证据:/api/cart 的状态码、请求失败原因(request.failure)、按接口数据算出的期望合计、页面显示的合计。
- 朴素版:页面显示"加载失败",或合计和期望不一致,就报 Bug。
- 谨慎版:先看网络证据。接口请求失败或返回 5xx,重新加载一次;仍失败,报
ENV_ERROR(无法判定)。只有接口 200 且合计和接口数据对不上,才报 Bug。
本地真实运行(bughunt_web/bughunt.py,Chromium):
健康 真相=PASS 朴素=PASS 谨慎=PASS 谨慎版加载次数=1 证据=(200, None, '已加载')
产品 Bug 真相=BUG 朴素=BUG 谨慎=BUG 谨慎版加载次数=1 证据=(200, None, '已加载')
连接被重置(持续) 真相=ENV 朴素=BUG 谨慎=ENV_ERROR 谨慎版加载次数=2 证据=(None, 'net::ERR_CONNECTION_RESET', '加载失败:Failed to fetch')
503(持续) 真相=ENV 朴素=BUG 谨慎=ENV_ERROR 谨慎版加载次数=2 证据=(503, None, '加载失败:HTTP 503')
API 慢 2 秒(持续) 真相=ENV 朴素=BUG 谨慎=ENV_ERROR 谨慎版加载次数=2 证据=(None, 'net::ERR_ABORTED', '加载失败:signal timed out')
连接被重置(只一次) 真相=PASS 朴素=BUG 谨慎=PASS 谨慎版加载次数=2 证据=(200, None, '已加载')
产品 Bug + 连接重置一次 真相=BUG 朴素=BUG 谨慎=BUG 谨慎版加载次数=2 证据=(200, None, '已加载')
误报 Bug:朴素 4 个,谨慎 0 个;漏报:朴素 0 个,谨慎 0 个
在 5 个不是产品 Bug 的场景里,朴素版误报了 4 个,谨慎版 0 个,两者都没有漏报。最后一行是反向检查:加了重试和网络证据,真 Bug 也不能漏掉。三个测试把这张表钉住:谨慎版的结论和真相逐行一致;误报数分别是 4 和 0;每个 ENV_ERROR 都附带了能说明原因的证据(请求失败或 5xx),并且是重试过一次才下的结论。
有一个取舍要说清楚:把"持续 5xx"判成环境错误,也可能盖住一个真的 500 Bug。所以 ENV_ERROR 不是丢弃,而是"无法判定":带着网络证据进人工分诊,既不计入找到的 Bug,也不计入漏报。
toxiproxy:在 TCP 层注入(只介绍)
前面的故障都注入在 HTTP 层以上。被测对象不是 HTTP(Redis、数据库、消息队列),或者没法改客户端的 base_url 时,就要在 TCP 层动手脚。Shopify 的
toxiproxy
是一个 TCP 代理,README 开头写的是 “Toxiproxy is a framework for simulating network conditions.":客户端连代理,代理连真实服务,中间按 “toxic” 注入故障;控制接口是 HTTP,默认端口 8474。本机没有安装 toxiproxy,下面的内容摘自它的 README(
40f7fd3
),没有实际运行。
| toxic | 作用 | 属性 |
|---|---|---|
latency | 所有数据加延迟,延迟等于 latency ± jitter | latency、jitter(毫秒) |
timeout | 拦下所有数据,到时间后关连接;设为 0 时连接不关,一直丢数据,直到删掉这个 toxic | timeout(毫秒) |
reset_peer | 模拟 TCP RESET(Connection reset by peer),立即或延迟一段时间后发生 | timeout(毫秒) |
limit_data | 传输的数据超过上限就关连接 | bytes |
bandwidth | 限速 | rate(KB/s) |
slicer | 把数据切成小块,可以在块之间加延迟 | average_size、size_variation(字节),delay(微秒) |
每个 toxic 还有两个通用字段:stream,取 upstream(客户端到服务端)或 downstream(服务端到客户端),默认 downstream;toxicity,表示作用到一条连接上的概率,默认 1.0。“整个服务挂掉"不是一个 toxic,而是把代理的 enabled 设为 false。README 里的 CLI 例子:
toxiproxy-cli create -l localhost:26379 -u localhost:6379 shopify_test_redis_master
toxiproxy-cli toxic add -t latency -a latency=1000 shopify_test_redis_master
和本章的故障对应起来:timeout 取 0 就是 TCP 层的"卡住的流”,limit_data 是 TCP 层的"中途断开”,reset_peer 对应 Playwright 的 abort("connectionreset")。
测开视角:故障注入清单
- 先写清楚什么结局算对:恢复后
done,或者明确的env_error。崩溃、挂起、半截当成功、环境故障报成 Bug,都要有测试防住。 - 按层列故障,每层选合适的工具:TCP 用 toxiproxy,HTTP 和 SSE 用假模型服务器,被测 Web 应用用
page.route。 - 每个故障都测两种持续方式:只坏一次,断言能恢复;一直坏,断言请求数有上限、结局是
env_error。 - 断言请求数和等待时间,不只断言结局。多重试一轮,结局一样是
done,但账单和限流配额不一样。首字节超时被放大到 9 个请求,就是靠请求数断言发现的。 - 超时按阶段分别设、分别测:连接、等响应头、两个事件之间、整个任务。SDK 的默认值不一定适合长任务 Agent。
- 随机注入用固定种子,先推预言再核对;故障率相同的情况下,还要测扎堆的故障。
- 测试 Agent 的结论要带证据:网络状态码、失败原因。拿已知没 Bug 的版本注入环境故障,断言误报为 0;拿已知有 Bug 的版本加暂时故障,断言没有漏报。
常见错误说法
- “SDK 自带重试,超时不用管”:SDK 的重试只覆盖拿到响应之前。流卡在半路,SDK 1.11.0 默认要等 600 秒,而且不会自动重试。
- “重试多几层更保险”:同一个错误在两层都重试,请求数相乘。实测首字节超时时,第 3 周的 loop 一次调用发了 3 × 3 = 9 个请求。
- “每种错误只在一层重试,请求数就不会相乘了”:混合故障下仍会。每轮先首字节超时、再流卡住,第 5 周的 loop 一次调用最坏还是 9 个请求;要压住总量,得加整次调用的预算。
- "
except anthropic.InternalServerError就能兜住服务端错误":SDK 1.11.0 默认客户端收到 529 抛的是OverloadedError,不是它的子类;流里的error事件抛的是笼统的APIStatusError。 - “页面报错就是 Bug”:先看网络证据。接口失败或返回 5xx 是环境问题,要重试,仍失败就标"无法判定"。
- “平均故障率 30%,重试 2 次就有 97.3% 的调用能成功”:只在故障相互独立时成立。故障扎堆、重试紧接着发出时,单次调用三次全坏的概率从 2.7% 升到 19.2%。
- “随机故障注入测出来的失败没法复现”:用固定种子生成故障计划,并记录每个请求注入了什么,就能原样复现。
下一周转向 Judge 与错误分析。