REST API 错误处理最佳实践:状态码、RFC 9457 与可重试错误

系统介绍 REST API 的错误状态码、RFC 9457 错误格式和可重试错误的处理方式,帮助建立一致的错误契约。

用 Apifox,节省研发团队的每一分钟

REST API 错误处理最佳实践:状态码、RFC 9457 与可重试错误

免费使用 Apifox

相关推荐

最新文章

API

一体化协作平台

API 设计

API 文档

API 调试

自动化测试

API Mock

API Hub

立即体验 Apifox
目录

你的 API 错误响应是其契约的一部分。客户端会解析它们,重试逻辑会据此分支,支持工程师会在凌晨 2 点搜索它们。然而,大多数团队会详细设计成功路径,却任由错误响应由框架的默认行为决定。这就是为什么你最终会在同一个 API 中得到三种不同的错误结构、一个用 200 响应包装 "success": false,以及一条将你的数据库架构泄露给公共互联网的堆栈跟踪信息。

本指南端到端涵盖 REST 服务的 API 错误处理最佳实践:选择正确的状态码、统一使用一个包含 RFC 9457 Problem Details,将机器可读代码与面向人类的消息分开、标记错误是否可重试,并避免在响应中泄露机密信息。本文基于我们对 REST API 应使用哪些 HTTP 状态码 的解析,并补充了该指南未涉及的契约层面决策。你还将看到如何在 Apifox,因为从未测试过的错误契约,就不是你真正拥有的契约。

从状态码开始,而不是响应体

HTTP 已经免费提供了第一层错误语义。 RFC 9110 定义了状态码类别:4xx 表示客户端做错了某件事,重复相同请求仍会失败;5xx 表示服务器发生了故障,而客户端的请求可能完全没问题。在编写任何错误响应体之前,先正确处理这一区分,因为通用客户端、代理、缓存和重试库都会根据它进行分支判断,完全不会读取你的 JSON。

最常见的错误集中在几个容易混淆的成对状态码上。设计时请将 MDN 的 HTTP 状态码参考 在设计时打开,并使用这张决策表处理那些最容易让团队犯错的状态码。

情况 使用 不要使用 原因
格式错误的请求:JSON 损坏、content type 错误、缺少必填字段 400 Bad Request 422 服务器完全无法解析或理解该请求
格式正确但违反语义规则的请求:amount 为负数、currency 不受支持 422 无法处理的内容 400 语法正确;但值不正确
没有凭据,或令牌已过期/无效 401 未授权 403 客户端尚未证明自己的身份。发送 WWW-Authenticate
凭据有效,但权限不足 403 禁止访问 401 身份已确认;但访问被拒绝。重新进行身份验证也无济于事
资源从未存在,或者你不会确认它是否存在 404 未找到 410 安全的默认选择;也能防止未经授权的探测者发现资源
资源曾经存在,但已被有意、永久地删除 410 资源已永久删除 404 告知客户端和爬虫删除它们的引用
状态冲突:重复键、版本过旧、编辑冲突 409 冲突 400 请求有效,但与当前资源状态发生冲突
客户端超过了速率限制 429 请求过多 503 始终包含 Retry-After 以便客户端正确退避
代码中未处理的异常 500 内部服务器错误 502 你的服务器出故障了
上游服务向你的网关返回了无效数据 502 错误网关 500 故障发生在边缘层下游,而不在边缘层本身
服务器过载或正在维护 503 服务不可用 500 按定义属于临时故障;添加 Retry-After (如果条件允许)
上游服务超时 504 网关超时 500 区分“缓慢的依赖项”和“损坏的代码”

其中两点值得特别强调。首先,401 与 403 是安全边界,而不是风格选择:向未经身份验证的调用方返回 403 会泄露资源存在这一事实。其次,不带 Retry-After 会让客户端养成在短间隔循环中不断轰炸你的习惯。如果你实施限流——而且你应该这样做——请将该状态码与明确的退避信号配对;我们的指南以 API 速率限制 为主题,涵盖请求头的计算方式以及背后的算法。

一种错误响应体结构:RFC 9457 Problem Details

状态码正确后,API 返回的每个错误都应共用一种媒体类型和一种模式。标准答案是 RFC 9457 Problem Details,以 。它定义了五个核心成员: application/problem+json (用于标识错误类别的 URI), type (简短的人类可读摘要), title (为方便起见重复出现的 HTTP 状态码), status (本次错误中发生了什么),并 detail (用于指向这次特定失败的 URI)。其他内容都放入你自行定义的扩展成员中。instance我们不会在这里重新推导该规范;我们的

RFC 9457 详解 会逐一介绍每个成员、注册表规则,以及它如何取代 RFC 7807。对你的契约来说,重要的是这种模式:标准封装,自定义扩展。下面是支付端点上的一次验证失败。其中的

POST /v1/payments HTTP/1.1
Content-Type: application/json

{ "amount": -1400, "currency": "USD", "source": "card_8xKt2" }
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Request validation failed",
  "status": 422,
  "detail": "One or more fields failed validation.",
  "instance": "/v1/payments/requests/req_9f3c1a7b",
  "code": "PAYMENT_VALIDATION_FAILED",
  "errors": [
    {
      "field": "amount",
      "code": "AMOUNT_NOT_POSITIVE",
      "message": "amount must be a positive integer in minor units"
    }
  ],
  "request_id": "req_9f3c1a7b"
}

数组是一个扩展成员,也是客户端最喜欢的成员:它让前端能够将每个失败映射到确切的表单字段,而不是显示一条含糊不清的提示条。请将字段路径保持为稳定格式(JSON Pointer 或点号路径,二选一),这样客户端代码就能以编程方式绑定它们。errors[]有一条规则能为你省下最多麻烦:对每个错误都返回这种结构,包括框架或网关生成的错误。客户端从你的处理程序获得 Problem Details,却从负载均衡器的 502 页面得到 HTML,仍然必须编写两个解析器。

机器可读代码与人类可读消息

注意,这个示例同时包含 和

字段。这是有意为之。它们面向不同的受众,绝不能合并成一个字符串。code机器可读代码(message

AMOUNT_NOT_POSITIVECURRENCY_UNSUPPORTEDIDEMPOTENCY_KEY_REUSED)属于契约的一部分。客户端会根据它们进行分支处理,因此它们必须稳定、有明确文档且可枚举。绝不要让客户端解析散文;一旦有人写出 , if (message.includes("positive"))你的文案修改就会变成一次破坏性变更。

人类可读消息则恰恰相反:可以随时改进,面向阅读日志的开发者,而且绝不承担关键逻辑。请说明失败原因以及修复方式:“amount 必须是以最小单位表示的正整数”胜过“amount 无效”。如果要本地化,就只本地化消息,代码保持不变。

如今 API 消费者还包括自主 Agent,这种区分就更加重要了。基于 LLM 的客户端从结构化且自描述的错误中恢复得好得多;我们在 面向 AI Agent 的 API 错误设计.

错误响应中绝不应出现的内容

错误响应是攻击者最喜欢的侦察渠道之一,因为未处理的故障往往会泄露过多信息。你的错误中间件应确保以下任何内容都不会到达客户端:

  • 堆栈跟踪、类名或文件路径
  • 原始 SQL、查询片段或 ORM 错误
  • 内部主机名、IP、端口或服务名称
  • 库版本和框架标识字符串
  • 嵌入异常文本中的密钥、令牌或连接字符串
  • 用户账户是否存在(在登录和密码重置流程中,应让失败响应保持一致)

模式很简单:在边界处捕获所有异常,在服务端使用请求 ID 记录完整异常,并返回一个带有相同 ID 的通用 Problem Details 响应体。客户端会得到 "detail": "An internal error occurred", "request_id": "req_51ad0", 而你的日志会记录真实情况,支持团队可以将两者关联起来。

将错误标记为可重试或终止性错误

你返回的每个错误都在回答客户端即将提出的一个问题:我应该再试一次吗?把这个答案固化在契约中,而不是让每个客户端团队自行猜测。

状态码承载默认语义。429、502、503 和 504 都可以重试,并应采用指数退避和抖动。500 的语义并不明确,但通常值得谨慎重试一次。几乎所有其他 4xx 状态码都是终止性的:使用相同请求重试 401、403、404 或 422 只会浪费配额并污染日志。超时需要特别谨慎处理,因为客户端放弃后请求可能已经成功;这就是经典的 408 请求超时问题,也是为什么变更类端点应接受幂等键,以免重试支付导致重复扣款。

你还可以通过扩展成员明确表示是否可重试:

{
  "type": "https://api.example.com/problems/rate-limited",
  "title": "Too many requests",
  "status": 429,
  "code": "RATE_LIMITED",
  "retryable": true,
  "retry_after_seconds": 30
}

显式的 retryable 标志让你可以在需要时覆盖默认行为,例如将特定的 500 子代码标记为终止性,因为重试会破坏状态。只需在文档中说明一次该标志,你发布的每个客户端 SDK 都能采用统一的退避行为。

关联 ID 与错误契约版本控制

还有两个较小的决策可以完善这份契约,而且现在做成本低,之后再做代价高。

为每个请求分配一个 ID。接受传入的 X-Request-Id 请求头(如果没有则生成一个),将它记录在每一行日志中,并在每个错误响应体中以 request_id。当客户将错误粘贴到支持工单中时,这个字段能把一小时的日志排查变成一次查询。在分布式环境中,同时传递 W3C traceparent 与其一同传递,让 ID 随请求在各个服务之间传播。

像 API 本身一样为错误契约进行版本控制。添加新的扩展成员或新的错误代码是安全的。重命名 errors[].field, 更改某个错误代码的含义,或从临时结构迁移到 Problem Details 都属于破坏性变更,而且会破坏团队测试最少的代码路径。这type URI 为你提供了一个清晰的机制:永久保持旧的类型 URI 稳定,为新的语义引入新的 URI,并在文档中说明,必须忽略未知的扩展成员和未知代码,而不能将其视为失败。正是这一向前兼容条款,让你无需推出 v2 也能持续演进。

在 Apifox 中测试每条错误路径

这里有个令人不太舒服的事实:错误契约会逐渐失效,因为没有任何测试会覆盖它们。每个演示都会运行成功路径;只有客户真正遇到 422 时,422 分支才会运行。解决办法是让失败场景成为测试套件中的一等公民,而这正是 Apifox 在工作流中发挥作用的地方。

有两个功能可以直接对应这一问题。

服务端测试场景。 对每个端点,为每种失败情况构建一个场景:缺少身份验证时预期返回 401,权限不足时预期返回 403,金额为负时预期返回 422,且 errors[0].code 等于 AMOUNT_NOT_POSITIVE, 突发流量预期返回 429,并带有一个 Retry-After 请求头。Apifox 的可视化断言无需编写脚本即可检查状态、请求头和正文中的字段;你还可以根据 Problem Details JSON Schema 验证完整负载,这样错误结构的任何漂移都会让 CI 失败,而不是让生产环境失败。我们的 API 断言指南 详细展示了断言模式。

客户端 Mock 服务器。 你的前端和 SDK 团队需要在后端能够按需生成这些响应之前,就针对 4xx 和 5xx 响应进行开发。Apifox Mock 服务器会根据你的 API 规范返回其中完整的 Problem Details 正文,因此你可以模拟一个 503,并带有 Retry-After: 120, 重复提交时返回 409,或一份完整的 errors[] 校验负载,然后观察客户端如何渲染并重试。无需手写 Express 存根,也无需注释掉后端代码来强制制造失败。

常见问题

验证错误应该使用 400 还是 422?

当请求格式错误、服务器无法理解请求时,使用 400:无效的 JSON、错误的内容类型,或缺少必填字段。请求可以被正常解析,但其中的值违反了领域规则时,使用 422,例如支付金额为负或货币不受支持。这样做的实际好处在于便于诊断:422 告诉客户端“修正你的数据”,而 400 表示“修正你的请求格式”。无论你选择哪种划分,都要在每个端点中保持一致。

什么是 application/problem+json?

这是 RFC 9457 为 Problem Details 定义的媒体类型,即 HTTP API 的标准 JSON 错误格式。使用此内容类型的响应携带 type, title, status, detail, 和 instance 成员,以及你定义的任何扩展,例如一个 errors[] 数组,用于字段级验证失败。使用注册的媒体类型,通用客户端和中间件无需自定义配置即可识别你的错误。我们的 RFC 9457 详解 涵盖完整规范。

客户端应自动重试哪些 HTTP 错误?

对 429、502、503 和 504 使用带抖动的指数退避进行重试;如果 Retry-After 存在,则遵循它。将 500 视为值得谨慎重试一次的错误。不要重试其他 4xx 响应;请求每次都会以相同方式失败。对于修改数据的端点,将重试与幂等键结合使用,这样重放请求就不会重复扣费或重复创建。

如何在不破坏后端的情况下测试 API 错误响应?

模拟这些错误。将客户端指向一个 Apifox Mock 服务器,使其返回规范中完全一致的 4xx 和 5xx 响应体,然后针对每种响应验证渲染和重试行为。在服务器端,编写测试场景,发送无效负载、缺少身份验证信息的请求,并制造突发流量,然后断言状态码、标头以及错误响应体结构。两部分都在 CI 中运行,因此无需任何人手动触发失败,错误契约也能保持真实可靠。

开发必备:API 全流程管理神器 Apifox

介绍完上文的内容,我想额外介绍一个对开发者同样重要的效率工具 —— Apifox。作为一个集 API 文档、调试、设计、测试、Mock、自动化测试于一体的工具,Apifox 是目前提升研发效率的首选。

如果你正在开发项目,不妨试试其极其友好的界面设计,它完全兼容 Postman 和 Swagger 数据格式,导入数据非常方便,,即使是新手也能很快上手,点击这里即可注册使用

Apifox

值得一提的是,除了个人和常规团队使用,针对有高安全合规要求、或需要在内网环境协作的企业,Apifox 还提供了深度定制的私有化部署方案

获取专属报价与部署方案

icon 详细的私有化部署系统架构与安全白皮书
icon 针对您公司规模的专属报价单
icon 免费的 1v1 专属产品演示 (Demo) 机会
获取部署方案
* 提交后,我们的客户经理将在 1 个工作日内与您联系
林俊锋 企业微信
@Apifox 专属顾问
扫码备注: 私有化 + 公司名