重试分支在新provider下要么成为死代码(未覆盖的过载状态码)要么死循环(将限额错误误判为过载),诊断特征是硬失败飙升但无对应重试;解决方案需明确区分容量型429和配额型429。
多步工作流中有一个分支,用于决定何时重试。这个分支是针对某一提供商的错误词汇表编写的,而在切换提供商之后,它要么变成死代码,要么变成无限循环。两种情况都很常见,且根源相同。
一行代码引发的两种失败
条件判断通常形如 if status in (429, 500, 502, 503): retry(),有时会辅以对提供商错误类型字符串的检查。迁移之后,它会朝两个方向之一失败。
死分支。新提供商用状态码表示过载,但该条件并未列出这个状态码,于是真正属于临时性的失败被漏到了终止路径。工作流将任务标记为失败,补偿逻辑开始运行,用户看到的错误其实本该在第二次请求时成功。从日志看,表现为硬失败激增,但没有对应的重试激增——这是诊断特征。
失控分支。恰恰相反,且代价更高。将 429 视为始终可重试的条件,现在捕获的根本不是容量问题的错误——消费限额达到、预付余额耗尽、组织级使用限制——某些提供商对这类情况也返回相同的 429 状态。这些错误永远不会重试成功。退避循环如果上限设置得宽松,会对每一个请求都重试到耗尽次数,不仅放大了延迟,而且在按请求计费而非按账户计费的场景下,还放大了支出。
这些状态码并非相同的状态码
Anthropic 的错误文档中记录了状态到类型的映射,其中包含一些在其他地方编写的分支不会有的值:402 billing_error、409 conflict_error、413 request_too_large、504 timeout_error,以及——最常击溃重试分支的那个——529 overloaded_error。列出 500、502、503 的条件无法匹配 529,所以最常见的临时性失败恰恰是漏掉的那个。错误响应体是一个顶层对象,type 设为字符串 error,内部有一个 error 对象承载 type 和 message,以及一个 request_id。
OpenAI 将 429 同时覆盖速率限制和配额耗尽,其下有明确的代码区分——其错误代码指南列出了 credit_balance_exhausted、organization_spend_limit_exceeded、project_spend_limit_exceeded 和 organization_usage_limit_exceeded,与普通速率限制并列,以及 503 表示引擎过载。这就是失控情况的完整描述:同一个状态码,一种含义可重试,四种含义应停止。
由此得出两条规则:只要提供商在状态码之下还有区分,就不要仅依据状态码做分支判断;不要假设你没有在该提供商文档中见过的状态码不存在。安全的默认值是:对未能辨认的 5xx 做有限次数的重试;对未能辨认的 4xx 立即失败,因为 4xx 意味着无论你发多少次请求,这个请求都不会成功。
那个实际上是错误的 200
每个基于状态的分支都会漏掉的失败,是中途错误。当请求流式响应时,提供商在知道请求会完成之前就承诺了 HTTP 200 并打开了事件流。如果在那之后发生了失败,这个失败会作为事件出现在一个成功的响应中。Anthropic 的错误文档明确说明了这一点——流式传输的错误处理不遵循标准的状态码机制——并指引读者去看流式格式中的错误事件。
一个将调用包装在 try 块中并检查 HTTP 状态的工作流,看到的是成功,于是继续处理已到达的部分文本。下游的症状是一条截断的回答,日志中却没有任何错误——这是从外部最难诊断的情况。修复方案是:流式消费者,而不是 HTTP 层,负责错误检测:它必须将错误事件视为异常,也必须将没有收到文档中规定的终止事件就结束的流视为异常。一个简单停止的流不是已完成的流。
相关的陷阱是:一种既不是失败、也不是完成的终止原因。Messages API 可以返回 pause_turn,这意味着轮次被中断,预期会继续;还有 model_context_window_exceeded,这是一种真正的失败,但以成功响应的形式到达,而不是 4xx。一个将除正常完成以外的任何情况都视为硬错误的分支,会中止一个本该恢复的轮次;一个将每个 200 都视为成功的分支,会静默接受上下文溢出。
归一化为你自己的分类体系
持久的修复方案是:不要再让提供商的词汇到达工作流。每个适配器将它接收到的任何内容映射到一个小的封闭集合,工作流的恢复逻辑就基于这个集合做分支判断,映射表是唯一需要了解状态码和类型字符串的地方。
export type Failure =
| "transient" // retry with backoff: overload, 5xx, timeouts, socket errors
| "throttled" // retry, but honour retry-after; capacity, not fault
| "exhausted" // account-level: spend cap, credit balance, usage limit — do NOT retry
| "invalid_request" // your payload is wrong; retrying is pointless — alert a human
| "auth" // key or permission; page whoever owns credentials
| "too_large" // reduce input and retry once, then fail
| "content" // refusal or filter; a different fallback, not a retry
| "unknown"; // conservatively: one bounded retry if 5xx, else fail
// The mapping table is per provider and is the only provider-aware code.
// Everything downstream branches on Failure and never on a status code.
两个特性使得这层间接抽象物有所值。每个类别对应唯一正确的操作,所以工作流的分支变成了一条无需任何判断的 switch 语句。而将 exhausted 与 throttled 分开作为独立类别,正是防止失控循环的关键——因为这是状态码本身无法表达的一种区分。日志行中保留原始提供商的值和归一化后的值在一起——归一化用于控制流,原始字符串用于支持工单。
content 类别值得拥有自己的路径,而不是重试。重试一个被拒绝的相同请求,会产生另一次拒绝并两次消耗预算;正确的做法是换一个 prompt、换一个模型,或者向用户展示一条消息——这是拒绝回退逻辑的主题。
在没有生产故障的情况下测试它
所有这些都无法在生产环境中验证,所以要注入故障。在适配器前面放一个测试替身,可以按指令返回每个支持提供商的每个文档化状态和类型字符串,外加一个在三个 chunk 后发出错误事件的流和一个简单停止的流。针对归一化后的类别和采取的行动做断言,而不是日志文本。
枚举每个提供商在其公开错误引用中列出的每个状态和类型字符串。这个列表很小,它是映射表的输入。
为每个条目写一个测试,断言归一化后的类别。新提供商因此只是一张表加上一组期望,而不是代码变更。
显式添加两个流场景,因为它们无法通过状态码触达。
添加预算断言:对于映射到 exhausted 的请求,上游调用的总次数必须恰好为 1。这是失控循环的回归测试,也是那个自我回报的测试。
升级 SDK 主版本时重新运行整个表。重试行为通常内置在客户端中——Anthropic 的 SDK 默认重试临时性失败两次,并遵循 retry-after——所以你的请求次数是两个重试层的产品,默认值变更会悄然将其倍增。
映射表是按提供商划分的,每个调用模型的服务的都需要相同的一份。这就是把它放在边界层而不是每个服务里的理由:像 Multigrid 这样的网关对外呈现统一的跨提供商错误分类,重试分支只需写一次。如果你是手工构建适配器,把这张表放在共享库中并对它做版本管理——失败场景是两个服务对什么可重试意见不一致。