用 fake transport 记录每次调用的 provider 名称,而非依赖返回值断言——两个 provider 返回相似内容时,仅测返回值会漏掉顺序错误。
一个从未发生过故障转移的回退链从未被测试过。在正常路径下,第一个 provider 回答了问题,测试变绿,而配置的顺序只是一个未经测试的假设存在于生产环境中。
你真正想要观察的不是答案——每个 provider 都会返回一个看似合理的结果——而是实际尝试过的 provider 序列。为 client 植入逻辑,在每次尝试时追加到一个列表中,然后对这个列表做断言。如果你的 client 没有暴露这样的接口,下面的 fake transport 可以自己记录它,这通常更简单,而且记录的是离开进程的内容,而不是你的代码打算做什么。
import pytest
CHAIN = ["alpha", "beta", "gamma"]
def test_fallback_follows_configured_order(router, fakes):
fakes.fail("alpha", status=503)
fakes.fail("beta", status=503)
fakes.ok("gamma", text="hello")
result = router.complete("hello")
assert result.text == "hello"
assert result.provider == "gamma"
assert fakes.attempts == ["alpha", "beta", "gamma"]
最后一个断言是关键。没有它,测试会在 router 先尝试 gamma、或者三个同时并行尝试并取最快结果的情况下通过——这种行为是合理的,但不是配置所声明的,而且会让每次请求的成本变成三倍,看起来却很健康。
链的有趣之处不在于一个场景,而在于从失败模式到预期尝试序列的映射,用表格写出来,空白一目了然:
CASES = [
# id, failures, expected attempts
("first-fails", {"alpha": 503}, ["alpha", "beta"]),
("two-fail", {"alpha": 503, "beta": 429}, ["alpha", "beta", "gamma"]),
("all-fail", {"alpha": 503, "beta": 503,
"gamma": 503}, ["alpha", "beta", "gamma"]),
("bad-request", {"alpha": 400}, ["alpha"]),
("auth-failure", {"alpha": 401}, ["alpha", "beta"]),
("timeout", {"alpha": "timeout"}, ["alpha", "beta"]),
]
@pytest.mark.parametrize("failures,expected", [(c[1], c[2]) for c in CASES],
ids=[c[0] for c in CASES])
def test_attempt_sequence(router, fakes, failures, expected):
fakes.configure(failures)
try:
router.complete("hello")
except router.AllProvidersFailed:
pass
assert fakes.attempts == expected
注意 all-fail 行既断言了序列,也容忍了异常。当每个 provider 都失败时抛出的错误值得自己单独做断言:它应该列出所有尝试过的 provider,并携带每个的最后一个错误,因为一个耗尽的链如果只报告最后一个 provider 的错误,会让所有人去调查 gamma,而实际上故障是 alpha 造成的。
构建 fake 时,要让一个 provider 由它的行为来定义,而不是由它所替代的 client 库来定义。一个返回状态码并记录自己被调用过的 fake 已经足够覆盖上面所有行,而且它让测试聚焦在路由上。一旦 fake 开始模仿 vendor 的响应 schema,你就写出了那个 vendor 的第二个实现,并且会永远维护它;如果需要真实的响应形状,从记录的 fixture 中获取,而不是从内存中回忆。
这是没人写但会烧钱的那个场景。400 是对你请求的声明。穿透意味着把同一个格式错误的请求发送到三个 vendor,在按量计费时最多付三倍的钱,而且返回的是第三个 vendor 的错误信息——描述的是一个读者从未见过的 schema。
穿透:429、500、502、503、504、连接错误、读取超时。这些是对 provider 的声明。
不穿透:400 和 422。下一个 provider 也会拒绝它,而且会更慢。
穿透但告警:401 和 403(认证相关)。一个 provider 的密钥过期不应该把你打垮,但必须大声——一条链悄悄地在第二选择上跑一个月,就是怎么产生意外成本的(轮换 API key 导致 CI 失败也是同样的故障,只是更早被发现)。
明确决定:内容策略拒绝。有些团队会穿透,希望另一个模型会接受,这是一个策略决定而非技术决定,无论如何都应该写在测试里(当 model 错误时用不同的 prompt 做回退)。
直接断言分类,一个测试对应一个状态码,这样为了修复无关的 flaky 而扩大重试谓词时,不会悄无声息地把 400 移入可重试集合。
用共享游标实现的链——一个模块级的"当前 provider"索引——在单个请求下行为正确,在十个请求下行为混乱。两个请求交错,一个推进了游标,另一个读取了它,观察到的顺序变成了时序的偶然。它永远不会在顺序测试中失败。
def test_order_is_per_request(router, fakes):
fakes.fail("alpha", status=503)
fakes.ok("beta")
logs = run_concurrently(router.complete, n=20)
for log in logs:
assert log == ["alpha", "beta"]
相关的交互是断路器。一旦 alpha 的断路器打开,正确观察到的顺序就变成了 beta、gamma——这是对的,但会让断言字面三元素列表的测试挂掉。在 fixture 中重置断路器状态,这样测试不会互相泄漏,并把打开断路器的顺序写成它自己的 case,而不是让它污染其他测试(测试断路器是否正确打开)。
一个三 provider 链,每个超时 30 秒,是 90 秒的最坏情况,这已经远远超过了用户离开和上游网关放弃的时间点。测试预算,而不只是顺序:
给每个 fake 一个可控的延迟,全部设为刚好低于每个 provider 的超时时间。
断言总耗时保持在请求 deadline 以下。使用可控的时钟而不是真实的 sleep,否则测试每次运行都要花 90 秒,最终会被删掉。
断言每次尝试的超时会随着预算消耗而收缩。正确的实现给第三次尝试只留剩余的时间,而固定超时链根本无法遵守 deadline。
当预算在链中途耗尽时,断言调用方收到的是 deadline 错误而不是通用失败——这是两种不同的事件,在日志里应该看起来不一样。
如果故障转移是在网关而不是在你的应用中处理的,这些断言就移到网关的配置上:链、哪些错误穿透的分类、以及每次尝试的 deadline 都变成设置而非代码。这就是 Multigrid 所做的权衡——一个请求,背后是一条配置的链——值得保留一个上面表格的小版本指向网关,因为你关心的属性还是"按这个顺序,而不是针对 400"。
provider 对于过载和策略拒绝返回哪些状态码因 vendor 而异,而且会变化。从当前 provider 的文档中导出你的分类表,而不是从上面的列表(那只是一个形状,不是规范)中。
Testing That a Circuit Breaker Opens After the Right Number of Failures
Testing Your App's Behaviour Under a Simulated 429 From Every Provider
Testing the Fallback Prompt Your App Uses When the Primary Model Errors