单 Provider 失败 + 备用成功只是及格线,真正危险的状态是两个都失败——此时代码通常:抛出最后异常、无穷重试或返回空 200,三种均比直接失败更差。
Failover 测试几乎总是在前一步停下来:主服务挂了,备用服务接管,绿色勾。真正有意思的状态是接下来那一步——备用服务也挂了,而这个状态之所以有意思,是因为在这种状态下,一个服务要么体面地降级,要么把一场本不是自己引起的故障放大成更大的灾难。
相关的 Provider 故障并不是什么稀罕事。两个 Provider 可能共享同一个云区域、DNS 解析器、上游模型托管商,或者你自己的出口代理;而完全发生在服务内部的故障——比如一组凭证过期、网络策略变更、你自己的 HTTP 客户端配置错误部署——会表现为所有 Provider 同时挂掉,因为它本来就是这样。
在这种状态下运行的代码,通常是没人读过的代码。它是一串 except 子句的最底层,或者遍历 Provider 的 for 循环的 fall-through 分支,通常会做三件毫无帮助的事之一:抛出最后一个 Provider 异常碰巧抛出的任何异常、永久重试整个调用链、或者返回一个空字符串的 200。三种做法都比体面失败更糟糕,而且测试成本都很低。
将失败模式参数化,而不是写一个测试。穷尽路径必须在 Provider 超时、拒绝连接、返回 500 还是返回 429 时表现完全一致,唯一知道这一点的办法是对每种情况跑相同的断言。
# test_all_providers_down.py
import httpx, pytest, respx
from myapp.llm import complete, AllProvidersFailed
PROVIDERS = [
"https://api.primary.example/v1/chat/completions",
"https://api.backup.example/v1/chat/completions",
]
FAILURES = {
"read_timeout": httpx.ReadTimeout,
"connect_error": httpx.ConnectError,
"server_error": lambda request: httpx.Response(503, json={"error": "unavailable"}),
"rate_limited": lambda request: httpx.Response(429, json={"error": "slow down"}),
"bad_gateway": lambda request: httpx.Response(502, text="<html>proxy</html>"),
}
@pytest.mark.parametrize("mode", list(FAILURES))
@respx.mock
def test_every_provider_down_raises_one_error(mode):
effect = FAILURES[mode]
routes = [respx.post(url).mock(side_effect=effect) for url in PROVIDERS]
with pytest.raises(AllProvidersFailed) as excinfo:
complete("hello")
assert all(r.called for r in routes), "a provider was never tried"
err = excinfo.value
assert err.attempts == sum(r.call_count for r in routes)
assert set(err.providers_tried) == {"primary", "backup"}
assert mode in err.summary or err.last_error is not None
bad_gateway 这个 case 值得单独说明:来自中间人的 502 返回的是 HTML 而不是 JSON,如果在错误处理器里写 response.json()["error"]["message"],就会在错误处理器内部抛出 JSONDecodeError。这就是穷尽路径停止产生你定义的错误类型、开始产生堆栈跟踪的方式——在你模拟一个非 JSON 响应体之前,这一切都是不可见的。
把你服务在所有 Provider 都挂掉时承诺的行为写下来,然后逐条断言。一份可用的契约包含四条:
无论原因如何,只抛出一种错误类型。 调用方应该基于"没有 Provider 能处理此请求"来分支,而不是基于五个底层异常中哪一个浮出水面。把失败原因作为异常的数据携带——providers_tried、attempts、last_error——日志行可以pickup 它们。
总尝试次数有上限。 断言确切数值。两个 Provider 各重试两次是四次请求;如果你的测试发现七次,说明你在两层嵌套了重试,这是小 Provider 抖动变成自我施加的负载突增的经典方式。
总时间有上限。 与单次超时 case 相同的技术:patch sleep,累加预期的延迟,断言总和时间在调用方 deadline 之内。穷尽在定义上就是最坏情况,所以这个数字决定了你的上游会不会在你身上超时。
调用方可以操作的状态码。 如果通过 HTTP 暴露,503 加 Retry-After 是有意义的,500 不是。在 API 级别测试中断言这个映射,因为从异常到状态的转换是独立于路由的代码。
穷尽路径是重试放大的诞生地,算术是冷酷无情的。假设你的客户端重试两次,你的网关层重试两次,你的调用方 SDK 重试两次。这些是相乘而不是相加:一次用户操作变成每个 Provider 8 次尝试,两个 Provider 就是 16 次。如果一个 Provider 正在因为过载而故障,你刚刚在它最能承受的时候把自己对它的负载贡献增加了十六倍。这些数字就是本段提到的三个重试计数的算术;代入你自己的,形状不会变。
两种结构性防御,都是可测的。 第一,决定哪个单一层拥有重试权,并断言其他层配置为零——对于 OpenAI SDK,是在客户端上设置 max_retries=0,一个构造你的客户端并断言这个值的测试比一行注释值钱得多。第二,在每个 Provider 前放一个断路器,这样在连续失败超过阈值之后,调用就完全停止。直接测试断路器的打开状态:让它超过阈值,然后断言下一次调用立即抛出且 call_count 不变。最后这个断言是断路器的全部价值——如果请求仍然发出去了,断路器就是装饰品。关于通用机制详见《AI 调用的断路器》。
一旦技术契约成立,剩余的问题就不是工程问题了:用户应该看到什么?选项是真实存在的而且各不相同,你写的测试取决于你选了哪个。
显式失败。 一个带有重试提示的错误。对于任何交互式场景都是正确的,也是模型输出本身就是产品时唯一诚实的答案。
排队。 接受请求,返回一个标识符,等 Provider 恢复再处理。对于批量和异步工作是正确的,测试断言任务是持久化入队而不是被丢弃。
走非模型路径。 用关键词搜索代替语义搜索,用模板代替生成的摘要。测试断言降级路径有明确标签——把模型输出静默降级为模板,是六周后关于质量"下滑"的支持工单的成因。
无论选哪个,断言请求被记录为一次穷尽事件,且带有用户可以引用的相同标识符。在真实的故障期间,你会被问到的问题是"有多少用户遇到了这个",答案必须来自一个事先就存在的计数器。
跨 Provider 的 Failover 大多是标准化问题穿了马甲:两个供应商对错误形状、限流头、流式事件名和结束原因各执一词,所以决定是否回退的代码必须理解所有这些。一个在多个 Provider 之上呈现统一 API 的网关把这层翻译移出了你的应用——这正是 Multigrid 所做的——但它没有移除那个分支。你仍然欠你的用户一个所有路由都穷尽时的明确定义行为,而且它仍然值得被测试。