第 4 章 可靠性与错误处理
学习目标
- 结合状态码、响应正文中的错误码以及响应头来解读错误,区分应该重试、应该修正,以及需要人工判断的情况。
- 解释速率限制的结构和429错误可能出现的多种原因,并据此调节流量。
- 在一个层级中设计包含抖动和预算的重试策略,确定各层的超时以及整体截止时间。
- 利用幂等键安全地重复执行有副作用的操作,并从结果未知的状态中恢复。
- 恢复中断的响应与部分失败的任务,防止故障蔓延到其他功能。
引言
凌晨两点,稿件校对任务停止了。负责人早上上班后,将任务从头重新运行。到了中午,编辑们告知,同一章的校对稿都上传了两份。查看日志发现,夜间交替出现了529和429错误,而重试逻辑对所有失败都按1秒间隔无限重复尝试。也就是说,对故障的应对方式,造成了比故障本身更大的损害。
在使用外部API的系统中,失败并不是偶发例外,而是日常情况。重要的是区分失败的类型,为每一种类型采取预先确定的应对措施,并且避免同一操作执行两次。本章从错误分类开始,依次介绍速率限制、重试与退避、超时、幂等性、中断响应与部分失败的恢复,以及故障隔离。
核心概念
成功、失败,以及结果未知
一次请求的结果并非只有两种,而是三种:收到成功响应、收到失败响应,或者没有收到任何响应。第三种情况称为结果未知。在连接断开之前,服务器可能已经完成了操作,也可能根本还没有开始。
如果只是模型调用本身,即使结果未知,也可以重新发送请求。这样只会多花一点费用,并不会改变外部世界。真正的问题出在有副作用的操作上。如果退款、创建预约、发送邮件的结果未知,却将其视为“失败”并重新执行,就可能把同一操作执行两次。对于结果未知的修改操作,应按以下顺序处理。
- 使用同一个业务键,查询操作的实际状态。
- 如果操作已经生效,就使用该结果,作为成功结果。
- 如果能够确认操作尚未生效,就使用同一个幂等键再次执行。
- 如果无法查询,不要自动重新执行,而应暂时保留为待处理状态,并通知人工。
错误分类表
各供应商命名不同,但状态码含义相近。
| 状态 | Anthropic | OpenAI | Gemini(Interactions API) | 应对 |
|---|---|---|---|---|
| 400 | invalid_request_error(包含达到自行设置的支出上限) |
无效请求 | invalid_request、failed_precondition |
修正请求或设置 |
| 401 | authentication_error |
身份验证错误 | — | 检查、更换密钥 |
| 402 | billing_error |
— | payment_required |
处理付款信息,不重试 |
| 403 | permission_error |
不支持的地区等 | permission_denied |
检查权限与访问设置 |
| 409 | conflict_error |
冲突 | — | 解决冲突后重试 |
| 413 | request_too_large(Messages API 32MB) |
— | — | 减小请求大小 |
| 429 | rate_limit_error |
多种原因(见下节) | rate_limit_exceeded、too_many_requests、quota_exceeded |
依原因而定 |
| 500 | api_error |
服务器错误 | api_error |
退避后重试 |
| 503 | — | server_is_overloaded |
service_unavailable |
退避后重试 |
| 504 | timeout_error |
— | deadline_exceeded |
考虑流式或批处理,检查客户端截止时间 |
| 529 | overloaded_error |
— | — | 退避后重试 |
Gemini文档明确不要重试400、402、403等客户端错误,OpenAI也说明,无效请求在修正前会得到相同结果。
筛选可重试错误的决策表
只看状态码不够。还需读取正文错误码与响应头,回答三个问题。
| 问题 | 是 | 否 |
|---|---|---|
| 相同请求过一段时间可能成功吗 | 速率限制、过载、暂时性服务器错误 | 格式错误、验证错误、支出上限、付款问题 |
| 重复执行安全吗 | 模型调用、查询、有幂等键的修改 | 无幂等键的修改操作 |
| 还有等待时间吗 | 距截止时间尚有余量 | 用户已离开或已过截止时间 |
仅当三个答案均为“是”时,才自动重试。第一项为“否”,应修正请求或通知人工;第二项为“否”,进入结果未知流程;第三项为“否”,确定失败并转备用路径。
速率限制的结构
速率限制是在一定时间内允许的请求与token限额,各供应商计量单位不同。
| 供应商 | 单位 | 范围 |
|---|---|---|
| Anthropic | 各模型档位每分钟请求(RPM)、每分钟输入token(ITPM)、每分钟输出token(OTPM) | 组织,可为工作区设更低限额 |
| OpenAI | RPM、RPD、TPM、TPD、每分钟图片数(IPM)等 | 组织与项目,部分模型族共享限额 |
| Gemini | RPM、每分钟输入token(TPM)、每日请求(RPD) | 按项目而非API密钥,RPD在太平洋时间午夜重置 |
Anthropic使用令牌桶算法:水以固定速度持续补入桶中,每次请求取走一部分,而非在固定时刻一次性重置。因此每分钟60次可能按每秒1次执行;即使每分钟总量在限额内,短时集中请求也会收到429。用量骤增可能触及加速限制(acceleration limit),文档建议逐步提高流量。
可通过响应头了解剩余额度。Anthropic提供anthropic-ratelimit-requests-remaining、anthropic-ratelimit-input-tokens-remaining等各项限额、剩余量、重置时间;OpenAI提供x-ratelimit-remaining-requests、x-ratelimit-remaining-tokens等。还要记住计算差异:OpenAI以max_tokens与预估token数中较大者计算token限额,缓存输入也计入TPM;Anthropic OTPM只计实际生成量,大多数模型的缓存读取不计ITPM。
如何解读429
同样是429,不同原因的应对可能完全相反。
| 原因 | 识别方法 | 应对 |
|---|---|---|
| 超过速率限制 | 通常有retry-after头(也可能没有) |
至少等待该时长后重试 |
| 流量突增 | Anthropic加速限制、OpenAI slow_down |
重试,同时放慢流量增长 |
| Anthropic档位支出上限 | 无retry-after,error.details.error_code为enforced_spend_limit_reached |
停止重试,SDK自动重试也会失败,通知负责人 |
| OpenAI余额、支出、使用限额 | 错误码表明余额耗尽或超限 | 重试无效,应充值或调整限额 |
| Gemini每日配额 | quota_exceeded |
等待重置或申请增额 |
同时记住,Anthropic达到组织或工作区自行设置的支出上限时,返回400 invalid_request_error而非429(Claude Code工作区限额例外,会返回带retry-after的429)。表中Gemini代码针对Interactions API,generateContent API采用RESOURCE_EXHAUSTED等其他格式。应根据具体原因进行分支处理。
指数退避与抖动
确定可重试后,还要决定等待多久。指数退避(exponential backoff)每次尝试将等待时间翻倍;抖动(jitter)加入随机波动,防止遭遇同一故障的大量客户端在同一时刻再次涌入。就像电梯前队伍散乱后,大家都精确等10秒再冲上去,仍会挤成一团。
function backoffMs(attempt: number, retryAfterSec?: number): number | null {
const base = 500, cap = 30_000;
if (retryAfterSec !== undefined) {
const wait = retryAfterSec * 1000 + Math.random() * 1000; // 最低等待时间 + 少量随机延迟
return wait > cap ? null : wait; // 超出上限则返回null:不重试,确定失败
}
const ceiling = Math.min(cap, base * 2 ** attempt);
return Math.random() * ceiling; // 全抖动:在0与上限之间随机选择
}
OpenAI文档建议,将Retry-After视为最低等待时间,再加少量随机延迟,同时限制尝试次数与总重试时间。还警告,失败请求也计入每分钟限额,不断重发只会使情况进一步恶化。
重试集中一处,控制在预算内
重试可能隐藏在多个层级中。Anthropic官方SDK默认针对连接错误、429和5xx进行2次指数退避重试,并遵守retry-after,可以通过max_retries修改。Gemini Python SDK针对暂时性错误最多重试4次,OpenAI官方SDK则默认针对连接错误及408、409、429、5xx重试2次。如果在此基础上,应用程序封装层、任务队列、API网关再分别增加自己的重试,尝试次数就会相乘。
AWS Builders' Library把重试形容为“自私”的行为。因为它为了提高自身请求的成功概率,会额外消耗服务器容量。因此,文档建议只在调用栈中的一个位置进行重试,并且只在依赖服务看起来健康时重试。具体实践方法如下。
- 确定唯一负责重试的层级。如果交给SDK,就关闭封装层的重试;如果由封装层负责,就把SDK重试次数设为0。设置项名称随SDK和语言而不同(例如Python为
max_retries,TypeScript为maxRetries)。 - 设置重试预算,包括尝试次数、总等待时间,以及重试占全部请求的比例(如10%)。可以在客户端设置令牌桶:每次收到正常响应时补入少量令牌,重试时消耗令牌,这样就能防止故障期间重试数量暴增。
- 在日志中记录原始请求ID和尝试序号。只有知道一次请求实际被传递了多少次,才能确保重试不超出预算。
分层设计超时
如果用一个超时设置处理所有等待,要么会过早中断,要么会占用连接太久。因此,应将超时分为三层。
| 层级 | 含义 | 确定依据 |
|---|---|---|
| 连接超时 | 等待TCP、TLS连接建立的时限 | 设得较短,通常为几秒 |
| 空闲超时 | 等待流中下一个事件到来的时限 | 以正常响应中测得的事件间隔高百分位数(如p99)为基础,再加上余量 |
| 整体截止时间 | 必须向用户作出答复的绝对时间 | 根据响应契约中的延迟目标确定 |
AWS介绍了一种方法:从下游服务的延迟分布中选择较高的百分位数(如p99.9),接受少量正常请求被误判为超时。截止时间应向下游传递。执行工具时,将剩余时间传给工具,不启动那些要到截止时间之后才能完成的操作。
生成时间较长的请求,应改用流式输出或批处理。Anthropic文档对可能超过10分钟的请求给出这一建议,因为某些网络会断开长时间处于空闲状态的连接。SDK会检查非流式请求是否可能超过10分钟,并设置TCP keep-alive;OpenAI SDK的默认超时同样是10分钟。根据OpenAI文档,同步响应可以通过断开连接来取消,但后台模式这类在服务器端执行的任务,必须显式取消才会停止。如果只是中间层断开了连接,而应用服务器向上游发起的请求仍然存活,生成和计费就会继续。
幂等性:按两次也只执行一次
电梯呼叫按钮即使按了很多次,也只会来一部电梯。同一个请求发送多次,结果仍与只发送一次相同,这种性质称为幂等性。HTTP标准(RFC 9110)将PUT、DELETE以及安全方法定义为幂等方法,并说明:客户端若要自动重试非幂等请求,必须知道该请求实际上具有幂等性,或者具备能够确认原始请求尚未生效的手段。AWS也指出,有副作用的API如果不提供幂等性,就无法安全地重试。
在应用程序中建立幂等性,可以采用以下方法。
- 为一个操作确定一个键,并在该操作的所有重试中使用同一个键。例如,根据工具调用ID构造键,或使用
refund:{订单号}:{退款轮次}这样的业务键。 - 接收方应对键设置唯一约束,并保存状态(处理中、完成、失败)、请求内容的指纹(哈希)以及处理结果。对键的占用应原子化(例如利用唯一约束插入),确保即使相同键同时到达,也只处理其中一个。
- 相同的键再次到达时,返回已保存的结果。如果键相同、请求内容却不同,则以冲突为由拒绝请求。
- 键的保留时间,应长于可能发生重试的整个期间。
模型给出的工具调用ID,每次调用都唯一,因此很适合作为构造幂等键的依据。同一次工具调用因网络错误而重发时,会携带同一个键,接收方由此能够识别重复。如果每次重试都创建新键,这一机制就失效了。另外,如果流程中会重新发送模型请求本身,模型就可能以一次新调用再次提出同一操作。这时,应把订单号等业务标识符也加入键中,让接收方能够识别它们属于同一操作。接收入站请求时也一样。OpenAI webhook偶尔可能将同一事件发送两次,因此文档建议使用webhook-id头来过滤重复事件。
恢复中断响应
流式输出即使已经收到200响应,也仍然可能失败。因此,需要在事件层面处理错误,并提前确定连接中断时采用的恢复策略。
- Anthropic:可以保存已经收到的部分,并请求继续接收。Claude 4.5及更早的模型,把已收到的内容放在一条新assistant消息的开头;Claude 4.6及之后的模型,则将已收到的内容与一条“从中断位置继续写”的user消息一起发送。工具使用区块和思考区块无法部分恢复,因此只能续接到最后一个文本区块,其余部分要重新请求。续写可能出现内容重复或衔接偏差,所以,对于结构化输出这类格式很重要的结果,从头重新请求更加安全。
- OpenAI:耗时较长的任务,可以用
background: true在服务器端运行,再通过轮询查询状态。如果与流式输出结合,可以把每个事件带回的sequence_number保存为游标,在连接中断之后用starting_after从该位置继续接收。在零数据保留(ZDR,zero data retention)项目中,以store=false运行,但响应数据仍会为了轮询而临时保存约10分钟。
展示尚未完成的结果,与确认业务已经完成,是两件不同的事。由中断响应生成的预览,应继续保持“生成中”状态。只有结果已经完整生成并通过校验后,才可以保存。
恢复部分失败
由多个单元组成的任务,经常会出现只有一部分失败的情况。处理原则是:“保留已经成功的部分,只针对失败的部分,按原因重新处理。”
- 并行工具调用: 如果部分调用失败,返回相应的失败结果,让模型决定下一步行动。对于没有执行的调用,也要返回包含未执行原因的结果(第2章)。
- 批处理: 将结果分为成功、错误、取消、过期。对于过期、取消以及服务器错误的请求,重新提交;对于请求格式错误,在修正之前不要再次提交。已经成功的条目,不要重新生成。
- 多阶段流水线: 按条目保存状态(等待、进行中、完成、失败、隔离),并在每个阶段结束时留下检查点。重新启动时,跳过已完成的条目。
- 不可撤销的操作: 对于发送邮件等无法撤回的操作,另外确定补偿流程(发送更正邮件、人工处理)。
对于反复触发相同错误的输入,应移入隔离队列(dead letter queue,死信队列),避免它们阻塞其他任务,并由人工检查原因。
防止故障蔓延
如果外部API长时间响应缓慢或不断失败,等待这些调用的线程和连接就会越积越多,连无关功能也会被拖慢。因此,需要像船舶舱壁一样,把损害限制在各自区域内的机制。
| 机制 | 行为 | 设置内容 |
|---|---|---|
| 队列准入限制 | 当等待中的请求超过一定数量时,立即拒绝新请求 | 最大等待请求数、拒绝时向用户显示的说明 |
| 资源隔离(舱壁) | 为不同功能分别设置并发执行数量和连接池 | 每个功能的并发执行上限 |
| 熔断器 | 失败率超过阈值时,暂停发起调用一段时间,之后仅放行一部分请求以确认是否恢复 | 失败率阈值、停止调用时长、试探请求数 |
| 备用路径 | 使用预先评估过的其他模型或供应商、缩减后的功能,或静态提示 | 质量标准、切换与恢复原路径的条件 |
熔断器有三种状态。就像电路一样,闭合时才会有电流通过。在正常(闭合)状态下,允许调用通过;在熔断(断开)状态下,立即转入备用路径;在试探(半开)状态下,若放行的部分请求成功,就再次闭合。等到故障发生后才选择备用模型,已经太迟。应平时就使用同一评估集确认备用模型的质量,并在日志和指标中留下切换记录。
深入阅读
剖析重试风暴
重试次数会在各个层级之间相乘。如果API网关重试1次,应用封装层重试3次,SDK默认重试2次,那么在持续失败时,一个用户请求最多会向API传递(1+1)×(1+3)×(1+2)=24次。故障期间,涌入的流量达到正常流量的数十倍,而失败请求也会消耗限额,因此恢复会更加缓慢。即使加入退避,这种相乘结构也不会改变。
防止重试风暴的顺序如下。首先,将负责重试的层级缩减为一个。其次,按比例设置重试预算:重试相对于正常请求超过一定比例时,就停止重试,尽快确定失败。第三,通过熔断器,在依赖服务不健康时改走备用路径,而不是继续重试。第四,故障结束之后,不要一次性释放所有积压任务。应分阶段提高处理量,避免再次触及加速限制。
注入失败以验证恢复策略
仅依靠正常响应进行测试,无法发现重试与去重逻辑中的缺陷。应在模型调用、工具调用之前加入模拟响应的故障注入层,复现以下情形。
| 注入故障 | 检查内容 |
|---|---|
带retry-after的429、无该头的暂时性429、带支出上限错误码的429 |
前两者退避重试,支出上限立即停止并通知 |
| 连续529、500 | 在预算内恢复或转备用路径 |
| 流中途断开 | 未完成结果是否未被保存 |
| 修改成功后立即丢失响应 | 是否按结果未知流程查询,且无重复执行 |
| 批次部分失败、同一webhook两次 | 是否未重做成功项,同一事件只处理一次 |
检查标准有三项:同一业务是否未执行两次,单请求实际发送次数是否在预算内,最终显示状态是否与事实一致。将此测试纳入部署流水线,每次修改重试逻辑都能发现回归。
工作场景
清泉净水器:60秒时被截断的报告
净水器租赁公司“清泉净水器”的上门技师应用,会在检查之前为每位客户生成水质历史报告。自从报告变长后,约15%的请求会失败,技师们点击重试按钮,常常使同一份报告生成两三次。实际支出的费用,比已生成报告数量所对应的费用高出了40%。
负责后端的资深工程师奉先生检查发现,应用服务器通过非流式方式调用模型,而企业内部API网关的请求超时设置为60秒。较长的报告需要70~90秒生成,因此连接会被网关中断;但是,应用服务器向模型发起的请求仍然存活,继续生成到结束并计费。此外,网关、应用封装层和SDK还分别进行了重试。
奉先生进行了以下修改。
- 将报告处理路径改为流式输出,并在网关中关闭这条路径的缓冲。超时设置改为“事件之间空闲30秒”和“整体截止时间4分钟”。
- 只在SDK这一处进行重试,关闭封装层和网关各自的重试。
- 技师关闭界面时,同时中断上游的流式请求。
- 对于计划次日上门的客户,前一天晚上就提前生成报告。只有当天临时增加的上门任务,才实时生成报告。
将网关超时延长至15分钟的方案没有采用,因为它会长时间占用连接,而且仍有空闲连接被中间网络断开的风险。同时发送两次相同请求、采用先返回答案的方案,则因为成本翻倍而被否决。修改之后,失败率下降到1%以下,重复生成也消失了。
松香出版社:无须从头重跑的校对流水线
出版社“松香出版社”将单行本的稿件按章拆分,生成校对建议,再存入稿件管理系统。某天夜间,处理12本稿件、380个章节时,接连出现529错误,任务因此停止。负责人早上将任务从头重跑。已经保存过的章节校对稿,又被保存了一次,花掉了两天的费用。其中一章包含大量高分辨率扫描图,每次请求都会超过大小限制,以413错误失败,却仍不断阻塞重试队列。
平台工程师陈经理重新设计了流水线。
- 为每个章节保存状态(等待、进行中、完成、失败、隔离)。重新启动时,跳过已经完成的章节。
- 保存时,使用
(稿件ID, 章号, 校对轮次)作为幂等键。相同的键再次到达时,返回已有的结果。 - 按照第4章的决策表分类错误。529、500及因速率限制出现的429,在预算内重试;413和400则不重试,直接放入隔离队列。被隔离的章节,由负责人拆分之后重新提交。
- 上线之前进行故障注入测试。在任务执行中途强制结束进程,然后重新启动,确认没有重复保存。
团队也考虑过将全部任务迁移到批处理API。这样虽然可以降低费用,但编辑们从早上开始就要按章节顺序审核,而批处理可能最多需要24小时,未能在期限内处理的条目还可能过期。因此,团队决定只在不紧急的再次校对轮次中使用批处理。之后,即使发生故障,重新启动时也只处理剩余章节,重复保存一次都没有再出现。
易错点
- 所有429只要等待就能解除。 因支出上限或每日配额产生的429,在限额恢复或调整之前,会持续失败。应通过响应正文中的错误码区分原因。
- 重试越快、次数越多越好。 失败的请求同样会消耗限额。因此,需要退避、抖动、次数和时间上限,以及重试预算。
- 400错误也可以多发几次试试。 请求格式的问题,在修正之前,无论发送多少次,都会得到相同结果。
- 发生超时,就说明服务器没有执行操作。 这时的状态是结果未知。对于修改操作,应先查询实际状态,再决定重新执行。
- 延长超时就能解决重复执行。 这样只能降低发生频率。根本的解决方法,是使用幂等键。
- 每次重试时都要创建新的幂等键。 一旦键改变,接收方就无法知道它们是不是同一个操作。同一操作的所有重试,都应使用同一个键。
- 在SDK重试之外再加应用程序重试,会更安全。 尝试次数会相乘,反而扩大故障。重试只能由一个层级负责。
要点速览
| 主题 | 需要记住的内容 |
|---|---|
| 结果状态 | 成功、失败、结果未知。结果未知的修改先查询 |
| 重试判断 | 以后可能成功吗、重复执行安全吗、还有时间吗 |
| 429 | 速率或骤增可等待重试;支出或配额应停止并通知。Anthropic自行设置的限额为400 |
| 速率限制 | 令牌桶也会限制短时突发,应逐步增加流量 |
| 退避 | 指数增长+抖动+上限,Retry-After是最低等待 |
| 重试层 | 仅一处,检查SDK默认值(Anthropic为2次) |
| 超时 | 连接、空闲、整体截止。长请求用流式、批处理、后台 |
| 幂等性 | 全程沿用同键(工具调用ID、业务标识)+唯一约束+保存结果。入站事件也去重 |
| 部分失败 | 保留成功,按原因处理失败,反复失败则隔离 |
| 故障隔离 | 准入限制、舱壁、熔断器、预先评估的备用路径 |
查阅官方文档
- Anthropic — API errors:阅读错误类型、SDK自动重试、请求ID和长请求建议。
- Anthropic — Rate limits:确认令牌桶、加速限制、支出上限429与响应头。
- OpenAI — Error codes:确认429各细分原因是否可重试。
- OpenAI — Rate limits:阅读
Retry-After、抖动与失败请求的限额消耗。 - IETF — RFC 9110 §9.2.2:阅读幂等方法与自动重试规则。
- AWS Builders' Library — Timeouts, retries, and backoff with jitter:阅读超时设置、重试放大与抖动。
自测
4-1. 说明为什么对于未收到响应的修改请求,应当将其视为“结果未知”而不是“失败”,并按顺序写出在结果未知状态下应做的事情。
4-2. 决定是否自动重试时,除HTTP状态码之外,还需要检查哪两类信息?请分别举一个会因此改变判断的例子。
4-3. 解释令牌桶方式的速率限制,与在固定时间重置的方式有什么不同,并说明为什么每分钟总量仍在限额之内,也会出现请求被拒绝的情况。
4-4. 分别定义连接超时、空闲超时和整体截止时间,并说明对于生成时间较长的请求,空闲超时为什么比单一请求超时更合适。
4-5. 写出接收幂等键的一方(付款或预约服务器)需要保存哪些信息,以及遇到键相同、内容却不同的请求时应如何处理。
4-6. 实时客服、文档摘要和夜间分析三个功能,共同使用同一个LLM API。请设计一张表,确定各功能的重试预算、并发执行上限、超时设置,以及发生故障时的行为。
实操练习
练习
故障注入重试封装(60分钟)
- 在模型调用之前,创建一个能够插入模拟响应的层。随机混入以下响应:带
retry-after的429、没有该响应头的暂时性429、带有支出上限错误码的429、529、500和400。 - 实现本章的决策表和退避函数,并关闭SDK的自动重试。为尝试次数、总等待时间和重试比例分别设置预算。
- 发送100个请求,按错误类型统计重试次数、最终结果,以及每个请求实际被传递的次数。
完成标准: 400和带支出上限错误码的429没有被重试过一次;其余错误在预算内得到恢复,或进入备用路径;每个请求实际传递次数的最大值没有超过设计值,即为完成。
练习
带检查点与幂等保存的流水线(60分钟)
- 创建一项任务,用模型逐个处理30份文档并保存结果。为每份文档在文件或DB中记录状态(等待、进行中、完成、失败、隔离)。
- 保存时以
(文档ID, 处理轮次)作为幂等键,让相同的键再次到达时,直接返回已有结果。 - 让其中两份文档始终产生不可重试的错误,并送入隔离队列。在处理第15份文档的过程中强制结束进程,然后重新运行。
完成标准: 重新运行后,已经完成的文档没有被再次处理,存储中不存在重复结果,而且能够列出被隔离的两份文档及隔离原因,即为完成。