AI 集成场景下的 API 错误分类体系
怎样设计一份 AI 生成的客户端真正用得上的 API 错误分类:稳定的 error code、机器可读的 category,以及区分可重试失败和永久失败的关键字段。
内容复查说明
复查日期:2026-05-06。本文已作为可索引的专题参考重新放出,并与 AI 编码治理 Hub 形成内链。内容保留了可复用的评审材料、失败模式和下一步阅读路径,适合直接用于实际团队讨论。
纯文本错误信息就是个陷阱
我见过的集成失败,栽在错误信息上的远多于栽在主流程上的。套路都差不多:服务端返回 400 Bad Request,body 里写一句 "Something went wrong processing your request. Please try again later."。人类运维看一眼大概能猜到下一步该干嘛,可程序化客户端猜不出来。而现在越来越多消费我 API 的是 AI 生成的客户端,它卡在两者之间——自信地编造出一套重试逻辑,把永久失败误判成瞬时失败,然后拿着一张已拒付的信用卡把客户的 rate limit 耗光。
解决方法不是把英文写得更漂亮,而是把 error envelope 的主要读者当成机器,人类只是次要读者。
光靠 HTTP 状态码撑不起契约
我评审过的每一个 API 都太依赖 HTTP 状态码。400 代表校验失败——除非它代表支付被拒,或者账户被冻结,或者 feature flag 没开。500 代表可以重试——除非你违反的那条数据库约束在接下来 47 次尝试里会继续失败。状态码只是个路由提示,不是一套分类体系。如果我让一个代码生成模型"碰到 5xx 就重试",它会一丝不苟地照做,结果有一半场景都是错的。
我依然会返回正确的 HTTP 状态码,只是不指望任何客户端仅凭它就能做决定。
我在生产里用的四字段错误信封
我服务返回的每个错误响应,都恰好有四个顶层字段,含义永远一致:
code——一个稳定、带版本语义的字符串,例如payment.card_declined.insufficient_funds。这是客户端唯一被允许用来分支判断的字段。没有走完弃用窗口,我绝不会改它的拼写。category——一个来自固定集合的机器可读分类值。当客户端认不出具体的 code 时,就靠这个字段驱动重试逻辑。message——一句给人看的话,不用来解析,这里也不做本地化,只是运维翻日志时能直接读的那一行。details——一个结构化对象。字段级错误、重试提示、trace ID,以及其他可能可操作的信息。
四个字段。再多就是压在消费者身上的认知税,再少则是把解析问题甩给客户端。
稳定的 code 才是关键
我把 code 写成 snake_case.hierarchical 的字符串——能做到就用三段式:domain、condition、subcondition。payment.card_declined.insufficient_funds。auth.token.expired。rate_limit.tenant.daily。层级结构意味着客户端就算不认识叶子节点,也能靠 domain 前缀做模式匹配,干点合理的事。一个 AI 生成的客户端,只读过我那一页参考文档,就能在没见过的 code 上给出正确的重试语义,因为前缀已经足够用来路由了。
我在评审里死守一条规则:code 一旦发布,就被冻结。我可以新增 code,可以给 code 标上 sunset 日期并弃用,但不能改名、拆分,也不能改变它描述的条件。违反这条规则,所有已经跑在外面的生成客户端都会跟我一起崩。
一套真正管用的 category 分类
category 字段是一个封闭集合。我用的就这八个值,从来没觉得需要第九个:
auth_required——凭证缺失或过期;客户端应刷新凭证后重试一次。forbidden——凭证有效但该操作不被允许;不要重试。not_found——资源不存在;同一个 ID 不要再试。validation——请求格式有问题;不改 payload 不要重试。rate_limit——慢点;按提示时长后重试。conflict——乐观锁或状态不一致;重试前先抓取最新状态。transient——上游暂时挂了;带 backoff 重试。permanent——请求按现在这样永远不会成功;抛给人处理。
这个列表小到可以背下来,边界也清晰到每个 code 正好对应唯一的 category。当 AI 生成的客户端认不出 code 时,它会回落到 category,照样能做对事。
字段级校验要用结构化数组
校验错误在 details 里有自己的形状。不能是单个字符串,不能是一坨 HTML,而是一个数组,每个条目都有 field、code、message:
"details": {
"field_errors": [
{"field": "email", "code": "validation.format.email", "message": "Must be a valid email address"},
{"field": "amount", "code": "validation.range.min", "message": "Must be at least 1"}
]
}
嵌套结构的 field 用点号路径表示(billing.address.postal_code)。code 与顶层 code 共享同一套稳定命名空间。表单 UI 不用解析英文就能把错误映射到输入框。生成的客户端也可以按字段 code 构造强类型异常。
重试提示要放在两个地方
响应可重试的时候,我会把提示发两遍:给懂标准的客户端用 Retry-After HTTP 头,给其他人用 details 里的 retry_after_seconds。冗余成本很低,指望每一个生成客户端都能正确读 header 的成本很高。我见过太多 SDK 悄无声息地把 header 丢掉,body 却解析得好好的。
对于 rate_limit,我还会带上 details.limit 和 details.remaining,让聪明的客户端可以自行调速,而不是一头撞进下一个 429。
关联 ID 不容妥协
每个错误 body 都带一个 details.trace_id,和我日志里的 ID 对得上。客户把错误贴进工单时,我希望一次复制粘贴就能把我带到 tracing 后端里那条精确的请求。这个 ID 是客户端可见失败与服务端证据之间的桥梁,也是我唯一从来没为添加它后悔过的字段。
本地化是元数据,不是身份
message 字段永远是英文。调用方想要本地化字符串,就传 Accept-Language,我会填充 details.localized_message。code 不会因 locale 而变。这个教训是从一个按语言返回错误码的 API 那里学来的——法语返回 error_carte_refusee,英语返回 error_card_declined——结果每一个跨区域的客户端都挂了。
一个真实的例子
下面是我某个服务对信用卡拒付的真实响应:
HTTP/1.1 402 Payment Required
Retry-After: 0
Content-Type: application/json
{
"code": "payment.card_declined.insufficient_funds",
"category": "permanent",
"message": "Card declined: insufficient funds",
"details": {
"trace_id": "01HX8K3M9P2QZ7N4R6S8T0V2W4",
"retry_after_seconds": 0,
"issuer_decline_code": "51",
"localized_message": null,
"field_errors": []
}
}
category 是 permanent,告诉客户端不要拿同一张卡重试。code 是 payment.card_declined.insufficient_funds,告诉聪明点的客户端应该提示换一张卡,而不是弹一个泛泛的"payment failed"模态框。trace ID 直接把支持人员带到日志里。
Given/When/Then 形式的验收标准
- Given a request that exceeds the per-tenant rate limit When the API rejects the request Then the response has code starting with "rate_limit." And category is "rate_limit" And details.retry_after_seconds is a positive integer And the Retry-After header matches details.retry_after_seconds And details.trace_id is present and matches the log record - Given a validation failure on two fields When the API rejects the request Then category is "validation" And details.field_errors contains one entry per failing field And each entry has field, code, and message populated
单页参考文档这个套路
我维护一个单页文档——就是字面意义上的一份 Markdown 文件——列出每一个 code、它的 category、是否可重试,以及一个示例 body。这一页既是我 SDK 生成器的单一事实来源,也是客户基于我的 API 写 AI 辅助客户端时的依据。新 code 上线前,先落到这一页,再进生产。当文档和运行时对不上时,文档赢,运行时就是 bug。一页、一份契约、一个查阅入口。
错误分类要让机器能做决定
“请求失败”不是错误分类。AI 生成的客户端会抓住你给它的字段做分支,所以错误响应至少要告诉它:是否可重试、是否需要用户动作、是否可以安全重放、是否应该报警。
{
"error": {
"code": "payment_method_declined",
"category": "user_action_required",
"retryable": false,
"safe_to_retry_with_same_idempotency_key": true,
"message": "Ask the customer for another payment method.",
"docs_url": "https://api.example.com/errors/payment_method_declined"
}
}
边界:不要把内部异常类名暴露成公开 error code。公开 code 是契约,一旦 SDK 和合作方依赖它,就要按 API 版本管理。
客户端测试也要进入规格
错误分类写完后,要补一个客户端测试:相同 code、category、retryable、message 字段进入旧 SDK 时,状态是否被正确映射,重试是否停止,日志是否保留 request_id。没有这条证据,API 团队只能证明服务端返回了 JSON,不能证明集成方真的能用。
最后给 taxonomy 加一条回归测试:同一个错误 code 在 API、SDK、日志表和告警规则里含义一致。字段名可以不同,语义不能漂。这个测试很便宜,却能提前发现“服务端可重试、客户端不可重试”的分裂状态。
关键词:API error taxonomy、error codes、retry semantics、machine-readable errors、API contracts、AI-generated clients
专题阅读路径
继续阅读
编辑说明与免责声明
最近复核:2026-05-06。编辑部检查了示例、专题内链和可复制评审片段,确保内容更适合真实项目使用。
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面