那些响应体显示 OK 且被客户端信以为真的 429 错误
故障始于 14:03,服务商返回了 429 错误,并带有一个 JSON 响应体,内容为 {"status": "ok", "data": null}。这个客户端库是六个月前由一个被坑过两次的人匆忙写成的——一次是因为网关返回了带有 error 字段的 HTTP 200,另一次是因为服务商在请求实际成功时返回了 HTTP 500。所以,这个库学会了信任响应体,而不是状态码。状态码要求限流,响应体却说继续。客户端相信了响应体,发出了下一个请求,又得到了一个带有 ok 的 429,再次发送,到 14:11 时,服务商的熔断器已将该账户在该小时的剩余时间内列入了黑名单。
服务商并没有完全撒谎。429 是真实的。但在响应流水线的某个环节,一个默认的封装层覆盖了限流负载——这是一个来自包装服务的通用 {"status": "ok"},用于填充缺失字段,并应用在了一个该包装服务无法识别的错误之上。状态码是正确的,请求头是正确的,响应体是错误的,而响应体正是客户端读取的部分。
这就是隐藏在每一个“我们有重试机制” 声明背后的失效模式。重试的前提是客户端能够读取错误。错误被分散在三个地方——状态码、请求头、响应体——当服务商发布变更时,这三个地方中的任何一个都可能独立地产生偏差。如果你的解析器只从其中一个提取信号,那么当服务商的另外两个信号失去同步时,你就会出错。
应该达成一致的三种真相
一个表现良好的错误响应有三个部分在表达同一件事。HTTP 状态码是粗略信号:429 意味着减速。Retry-After 请求头是时机信号:等待这么多秒。响应体是诊断信号:说明你触及了哪个限制以及原因。
这三者本应是冗余的。但在实践中,它们是由不同层级产生的。状态码由网关设置。请求头由限流器设置,而限流器可能距离网关有一跳之遥。响应体由应用程序设置,而应用程序可能根本不知道触发了限流——它的处理器从未运行。当服务商发布重构,将限流从一层移动到另一层时,其中一部分就会变得过时。另外两部分独自承载着真相,而读取过时信息的那部分客户端现在就像在盲目飞行。
Lambda 集成是经典的例子。当 Lambda 函数抛出异常时,API Gateway 会返回 HTTP 200,并带有 X-Amz-Function-Error 请求头和错误响应体。HTTP 状态是成功。请求头和响应体却说是失败。一个在非 2xx 时重试的简单客户端永远不会重试。一个解析 response.error 的简单客户端则会无限重试。孤立来看,这两者都不是错误的代码——它们错在针对这个特定网关产生的特定封装,而网关并没有提 前告诉任何人。
同样的模式随处可见。OpenRouter 将上游服务商的错误透传为 {"error": {"code": 429, "message": "Provider returned error: ..."}},并包裹在一个外部 HTTP 状态码中,根据捕获失败的层级不同,该状态码可能是 502 或 503。外部状态表明网关不正常。内部代码表明模型服务商正在对你限流。一个仅读取外部状态的重试策略会将永久性的配额耗尽视为暂时的网关波动,并持续猛击同一个端点。
为什么客户端会信任错误的一半
大多数客户端选择单一的真相来源,因为另一种选择是编写决策矩阵,而决策矩阵是错误处理中没有人愿意主动承担的部分。被选中的来源通常是响应体,因为响应体是应用程序可以放置结构化字段的地方,而结构化字段比 HTTP 语义更容易进行测试。
这在出问题之前一直行之有效。响应体是响应中最容易在重构过程中发生偏差的部分。状态码被负载均衡器、网关和 HTTP 库锁定;更改它需要动到底层架构。响应体归属于编写处理器的人,而处理器每季度都会重写。第一次改变响应体结构的重构会悄无声息地破坏所有依赖它的客户端。与此同时,状态码已经五年没变过了。
流式响应让情况变得更糟。在服务器发送事件 (SSE) 中,HTTP 状态是在连接打开时设置的——它只能是 200,因为响应体还没有开始。流式传输过程中发生的错误会作为数据事件到达,其形式类似于嵌入在内容块之间的 {"error": {...}}。一个根据开启时的状态码判定“成功”的客户端会累积部分输出,并将结果视为完整。有记录显示,某些 SDK 在流式传输中途遇到错误事件时会崩溃,因为它们的解析器假设每个事件都会有一个 choices 字段;还有一些情况,空的 SSE 元事件(retry: 指令、注释行)会引发 JSONDecodeError 并导致流中途终止。在每种情况下,HTTP 状态都承诺了一件事,响应体却交付了另一件事,而客户端对这种落差没有任何应对模型。
- https://learn.microsoft.com/en-us/azure/architecture/antipatterns/retry-storm/
- https://evolink.ai/blog/fix-openrouter-429-provider-returned-error
- https://help.openai.com/en/articles/5955604-how-can-i-solve-429-too-many-requests-errors
- https://cloud.google.com/blog/products/ai-machine-learning/learn-how-to-handle-429-resource-exhaustion-errors-in-your-llms
- https://zuplo.com/learning-center/http-429-too-many-requests-guide
- https://github.com/BerriAI/litellm/issues/8448
- https://github.com/openai/openai-python/issues/2722
- https://github.com/openai/openai-go/issues/556
- https://github.com/n8n-io/n8n/issues/27439
- https://docs.aws.amazon.com/apigateway/latest/developerguide/handle-errors-in-lambda-integration.html
- https://portkey.ai/blog/retries-fallbacks-and-circuit-breakers-in-llm-apps/
