API 的 Spec-First 错误处理模式
错误处理是 API 规格里最容易被含糊带过的一块。所有人都同意它很重要,但没人把它写下来,然后到了联调那一周,团队就开始争论超时到底该是 500 还是 503、客户端该不该重试。规格的职责就是在任何人动手写 handler 之前,把这些问题全部回答掉。
一份信封,所有端点
我最坚持的第一条决策,是整个 API 只用一个错误信封。不是每个端点一种形状,也不是每个团队一种形状。就一个对象,字段稳定到每个客户端都能依赖:一个稳定的错误代码字符串、一条给人看的消息、一个 retryable 布尔值、一个用于校验失败的可选字段路径,再加一个 request id。如果一个端点返回 {"error": "..."}、另一个返回 {"message": "..."}、第三个返回 {"errors": [...]},每个客户端都得写三套适配器,然后把它们塞进一个 switch 里。
把信封写进规格几乎不花什么成本。等到六个端点已经用不同形状发布出去之后再来改,就是一整个季度没人愿意认领的活。
我真正在用的 4xx vs 5xx 判据
我的试金石是这样:如果客户端把完全相同的字节再发一次,请求成功了,那就是 5xx;如果客户端必须改点什么才能成功,那就是 4xx。就这么简单。数据库瞬时抖动不是 400,格式错误的 JSON 不是 503。我把这句话一字不差地写进我写的每一份 API 规格里,因为工程师一累,这条线就开始模糊。
有趣的边界情形要单独明确:唯一约束冲突是 409,不是 500,因为是客户端引起的;下游供应商超时是 5xx,因为重试同一请求是可能成功的;限流是 429,因为客户端稍后用同样的字节就能成功,只是此刻不行。
校验:选 400 还是 422,然后别再吵了
我不在乎你选哪个,我在乎的是规格得选一个。团队会为 400-vs-422 的争论浪费好几周时间,而这本来一行规格就能解决。我的默认是:语义校验失败(字段值不对、取值互相冲突、业务规则违反)用 422;语法失败(JSON 格式错误、缺必填字段、Content-Type 不对)用 400。但任何一致的规则都行,不一致才是 bug。
在文档里写这条规则时,把它和信封定义写在一起,让人不可能漏看。然后每个 handler 在评审时都指向同一页。
批量端点里的部分成功
批量端点是规格悄悄崩掉的地方。你 POST 10 条到 /bulk-update,3 条校验失败、7 条成功。服务端有三种合理选择,规格必须押注其中之一:
- 207 Multi-Status,带每一项的状态列表。诚实,但冗长。
- 200 OK,返回一个 results 数组,每一项带各自的结果。务实,和统一信封配合得不错。
- 原子 4xx。只要有一项失败,整个批次就拒绝,什么都不落库。完整性保证最强,人体工学最差。
三种都说得通。在规格里选一个,意味着客户端只写一条代码路径;不选,意味着每个客户端都把三种都写了,然后猜错的时候来提工单。对于一次 POST /items/bulk 十项中有三项校验失败的情形,我默认的响应体长这样:
HTTP/1.1 200 OK
{
"request_id": "req_7fK2...",
"results": [
{"index": 0, "status": "ok", "id": "itm_01"},
{"index": 1, "status": "ok", "id": "itm_02"},
{"index": 2, "status": "error", "error": {
"code": "validation_failed",
"message": "price must be non-negative",
"field": "price",
"retryable": false
}},
{"index": 3, "status": "ok", "id": "itm_04"},
...
],
"summary": {"ok": 7, "error": 3}
}
注意每条子错误上的 retryable: false。这是我一直在坚持的第二件事。
把 retryable 显式地写出来
我的信封里每个错误都带一个 retryable 布尔值。不靠状态码隐含、不靠错误消息推断、不靠月相猜测。服务器说 retryable: true,客户端就带退避重试;说 false,客户端就把错误往上抛、停下来。这一个字段让每一个 SDK 作者都免于手写"状态码到重试策略"的映射表,也让服务端以后可以改变主意而不打破客户端。
retryable 这个字段还会逼出一场设计讨论。如果一个端点会返回 retryable: true,规格就必须同时说明它是不是幂等的。没有幂等键的重试,就是向客户重复扣款的那条路。
幂等性和限流要进规格
任何有副作用、又宣称失败可重试的端点,都需要一份 Idempotency-Key 请求头契约。规格要说明:这个 key 是客户端生成的字符串,保留 24 小时,重放会返回原始响应并带上 X-Idempotent-Replay: true 响应头。写一次,所有 POST、PATCH、DELETE 都复用这个模式。
限流也一样。429 配 Retry-After 是基本盘。规格还必须写清限流的维度:是按 API key、按 IP、按组织、按路由、按分钟、还是按小时。知道自己"每个 key 每分钟 1000 次"的客户端会去搭令牌桶;只看到 429 的客户端只能瞎猜。
Auth、下游和 Webhook 的信号
有三处规格必须表态、但通常没有表态的地方:
- 401 vs 403:401 表示调用方还没通过认证(token 缺失或无效);403 表示通过了认证但无权访问。把这件事搞错,会训练客户端在权限错误上反复触发认证流程,支持工单会被淹没。
- 下游失败:支付处理方超时是 503;支付处理方拒绝一张卡是 4xx,并映射到一个领域错误码。绝对不要把供应商名字泄漏到错误码里(
stripe_timeout要变成payment_provider_unavailable)。规格应该列出每一个下游依赖和它的失败映射。 - Webhook 确认:接收方返回 2xx 表示收到,4xx 进死信队列,5xx 要求重试。规格要写明具体状态码以及重试节奏(例如 24 小时内指数退避,然后进死信)。那些因为"框架默认这么干"而在失败时返回 200 的接收方,就是 webhook 静默坏掉的根源。
用 Given/When/Then 写验收标准
上面这些规则只有被写成验收标准才是可执行的。下面是我给前面那个批量端点写的块:
- Given a POST /items/bulk with 10 items And 3 items fail server-side validation When the request is processed Then the response status is 200 And the body contains results[] with status "ok" or "error" per item And the summary totals match the results array And each error has code, message, field, and retryable=false - Given a POST /items/bulk during a downstream outage When any item fails due to the outage Then the entire response status is 503 And the envelope error.retryable is true And Retry-After is present - Given a repeated POST with the same Idempotency-Key within 24h When the original request succeeded Then the response body matches the original And X-Idempotent-Replay is true
三个块,然后实现、测试和客户端 SDK 都有了同一份参考。
我拒绝放过的几件事
我在评审一份 API 规格时,会扫六样东西,少一样就把草稿打回:错误信封、4xx/5xx 判据、校验状态码的选择、部分成功模型、retryable 标志、幂等性契约。其他东西都可以以后再澄清,这六样不行,因为它们决定了每一个将来要接入的客户端长什么样。写下来花一小时,不写下来让团队赔上一个季度。
错误契约清单
错误处理可靠的前提,是客户端能根据稳定字段分支,而不是解析一段文字。让契约保持小、明确,并同时从服务端和客户端测试。
- 定义一个响应 envelope,稳定包含 code、category、retryable 和 correlation ID。
- 区分校验失败、鉴权失败、限流、依赖失败和幂等重放。
- 为每一类可重试错误写明重试行为,服务端能估算时提供 Retry-After。
- 至少给一个终止性错误和一个可重试错误写客户端示例。
- 发布契约前测试幂等键、部分失败行为和重复请求。
上线前准备三份错误样本
我不太信“错误处理已经统一”这种说法。上线前直接看样本最快。服务端、前端和 SDK 都用同一组样本跑一遍,很多争议会立刻暴露。
错误样本包 validation_failed: - status: 422 - retryable: false - field: email - user copy: 邮箱格式不正确 dependency_timeout: - status: 503 - retryable: true - Retry-After: 30 - client action: 延迟重试,不显示成功 idempotent_replay: - status: 200 - retryable: false - header: X-Idempotent-Replay: true - client action: 复用原结果,不再次提交
样本要进契约测试,也要进 SDK 文档。否则服务端以为自己讲清楚了,客户端却还在靠字符串判断错误类型。
需要追踪的证据
追踪让客户端困惑的工单、未分类的 5xx、重试风暴、缺失的 correlation ID,以及 SDK 无法区分终止错误和可重试错误的分支。这些信号能说明错误规格是在发挥作用,还是已经漂移。
专题阅读路径
这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。
继续阅读
填写表单,生成完整的功能规格 Markdown——免费使用,无需注册。
编辑说明
最近复核:2026-04-28。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。
本文面向软件交付团队,介绍API 的 Spec-First 错误处理模式。示例均为工程场景说明,不构成法律、税务或投资建议。
- 作者信息:Daniel Marsh
- 编辑政策:文章审阅与更新方式
- 纠错:联系编辑