Skip to content

错误处理规范

本章导读:错误响应是 API 使用频率最高的"文档"——调试时客户端第一个读到的是它。IETF 已经把标准答案写成了 RFC 9457(Problem Details for HTTP APIs,取代 RFC 7807)。本章给出规范字段的完整语义、错误码体系设计、以及各状态码下的错误实践细节。

1. 为什么"永远 200 + code"是错的

json
// 反面教材
HTTP/1.1 200 OK
{ "code": 404, "msg": "文章不存在" }
  • SDK 的异常映射、网关的重试/熔断、监控的成功率统计全部失灵——HTTP 层看一切正常;
  • 缓存层会把错误响应当 200 缓存(除非逐条配 no-store);
  • 浏览器/代理无法区分 401 触发登录流程。

原则:状态码表达"错误的大类"(协议语义),响应体表达"错误的细节"(业务语义)。

2. RFC 9457:Problem Details

错误响应的标准媒体类型:application/problem+json(也有 XML 变体 application/problem+xml)。五个注册字段:

json
// HTTP/1.1 422 Unprocessable Content
// Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "请求校验失败",
  "status": 422,
  "detail": "字段 title 超过最大长度 200(实际 233)",
  "instance": "/v1/articles",

  // 允许扩展成员(RFC 9457 §3.2)
  "code": "VALIDATION_FAILED",
  "errors": [
    { "field": "title", "constraint": "maxLength", "expected": 200, "actual": 233 }
  ],
  "traceId": "0af7651916cd43dd8448eb211c80319c"
}
字段语义规范要求
type机器可读错误类型的 URI,指向人类可读文档默认 about:blank;用语义化路径作错误码目录(见 §3)
title类型的简短人读摘要按语言可本地化(内容协商 Accept-Language);同一 type 的 title 应稳定
status绑定的 HTTP 状态码必须与真实状态码一致(RFC 9457 校验点)
detail本次错误实例的人读解释可本地化;包含具体上下文(哪个字段、什么值)
instance本次错误发生的具体上下文标识(URI 或 ID)可指向日志系统里该请求的记录(配合 traceId)

NOTE

RFC 9457 相比旧 7807 的两点重要更新:① 明确 type 可以去解引用(它就是标识符,不是必须能打开的网页——但提供文档页是良好实践);② 对 Content-Type 后缀协商与扩展成员的兼容性做了更严格的规定。

3. 错误码体系:用 type URI 建目录

type 的最后一段当作全平台唯一的机器可读错误码,错误码即文档 URL:

https://api.example.com/problems/article-title-too-long
https://api.example.com/problems/payment-balance-insufficient
https://api.example.com/problems/invalid-cursor

治理规则:

  1. 一码一义:禁止 PARAM_INVALID 走天下;粒度到"客户端能据此分支处理"为止;
  2. 命名:小写 kebab,资源 + 问题(article-title-too-long),新增走评审(它是公开契约);
  3. /problems/{code} 页面自动从错误目录生成(文档即实现);
  4. 兼容旧客户端需要数字业务码时,放扩展字段 code不要塞进 type

4. 状态码 × 错误场景对号入座

场景状态码响应体要点
JSON 语法错误、参数类型错400 Bad Requestdetail 指明具体字段
未认证 / Token 过期401 Unauthorized必须WWW-Authenticate: Bearer error="invalid_token", …(RFC 6750)
已认证无权限403 Forbidden可不解释细节(防探测)
资源不存在(或按安全策略对无权限者伪装不存在)404 Not Found无 body 也可
方法不支持(对 /articles/42 发 POST)405 Method Not Allowed必须Allow: GET, PUT, DELETE(RFC 9110)
Accept 无法满足406 Not Acceptable列出可用表示
请求体超过大小上限413 Content Too Large可带 Retry-After;上传场景给上限值
媒体类型不支持415 Unsupported Media Typedetail 给出期望的 Content-Type
缺少必需的条件头(强制 If-Match 的接口)428 Precondition Required(RFC 6585)
条件头不满足(ETag 版本过旧)412 Precondition Failed给当前版本信息,指导重试
业务规则冲突(重复昵称、状态机非法迁移)409 Conflicttype 区分具体规则
语义校验失败(格式对但值非法)422 Unprocessable Content(源自 WebDAV RFC 4918,RFC 9110 正式收录并更名)errors[] 逐字段
请求实体不完整(如分片顺序乱)422400,团队统一约定
触发限流429 Too Many Requests(RFC 6585)必须Retry-After;建议 RateLimit 头(见限流章)
服务内部异常500 Internal Server Error绝不回显堆栈/SQL;detail 用通用文案 + traceId
上游依赖失败502 Bad Gateway同上
过载/维护503 Service Unavailable应带 Retry-After(RFC 9110)
网关等待上游超时504 Gateway Timeout
资源曾存在已永久删除410 Gone与 404 区分,帮客户端清理缓存

TIP

高频辨析:400 vs 422——能不能正确解析+字段级语法错误 → 400;语法全对但业务/语义校验失败 → 422。401 vs 403——"你是谁"→401;"你不能"→403。404 vs 405——URL 对方法错是 405,不是 404。

5. 校验错误的批量返回

一次提交多个字段错误时,全部返回而不是"发现第一个就报":

json
// HTTP/1.1 422 Unprocessable Content
{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "请求校验失败",
  "status": 422,
  "detail": "2 个字段不符合约束",
  "instance": "/v1/articles",
  "errors": [
    { "field": "title",    "constraint": "required" },
    { "field": "publishedAt", "constraint": "mustNotBeBefore", "argument": "createdAt" }
  ]
}
  • field请求体中的 JSON Pointer(RFC 6901)定位深层结构:"field": "/author/address/city"
  • 表单/查询参数错误同理给 parameter + location(query/path/header)——参考 OpenAPI 的 Parameter 描述维度。

6. 安全红线

  1. 不回显内部信息:堆栈、SQL、文件路径、服务器版本(Server 头最小化);5xx 的 detail 面向用户,内部诊断靠 traceId 关联日志。
  2. 认证前不泄露资源存在性:未登录访问他人资源返回 404 而不是 403(防枚举)——但要全 API 一致,否则反而成了信号。
  3. 错误响应同样禁缓存或短缓存:Cache-Control: no-store(401/403/429 等含敏感语义)。
  4. instance/detail 不得包含用户输入原样回显(XSS/日志注入),输出前转义截断。

7. 框架落地示例(Spring Boot)

Spring 的 ProblemDetail(Spring Framework 6 / Spring Boot 3+)原生支持 RFC 9457:

java
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY,
        "字段 title 超过最大长度 200(实际 " + title.length() + ")")
    .setTitle("请求校验失败")
    .setType(URI.create("https://api.example.com/problems/validation-failed"))
    .setInstance(URI.create("/v1/articles"))
    .setProperty("code", "VALIDATION_FAILED")
    .setProperty("errors", List.of(Map.of(
        "field", "title", "constraint", "maxLength", "expected", 200)));

全局异常处理器统一转 Problem,保证任何 5xx 也不会漏出默认错误页格式。

8. 本章小结

  • 错误响应标准化三件套:正确的状态码 + application/problem+json(RFC 9457)+ 平台级 type 错误码目录
  • 扩展字段(code/errors[]/traceId)合法且必要——协议字段给机器与中间件,扩展字段给业务客户端与排障。
  • 状态码义务别忘了头部搭档:401→WWW-Authenticate、405→Allow、429/503→Retry-After

9. 下一步

设计规范齐了,进入第 04 篇把状态码全表过一遍 → HTTP 状态码详解