第一位数字决定谁来处理:4xx 是客户端需要修改请求,5xx 是服务端失败且通常可以稍后重试。401 表示未认证,403 表示已认证但无权限,409 表示状态冲突,422 表示语义校验失败,429 应配合 Retry-After 表示限流。
- 4xx 是客户端可修正的错误;5xx 是服务端问题,通常可重试。
- 401 是未认证,403 是已认证但无权限。
- 400 用于请求格式错误,422 用于格式正确但语义校验失败。
- 409 表示状态冲突,例如重复创建或版本过期。
- 返回 200 加错误正文会破坏客户端、监控和重试逻辑。
HTTP 状态码是客户端、服务端和中间层之间的契约。状态码错了,重试、缓存、监控和用户提示都会做出错误判断。
这篇文章聚焦 API 中最容易混淆的状态码,以及最快的排查顺序。
状态码写错的代价,很少停留在“报错不好看”。它会决定缓存存什么、重试中间件做什么、监控是否叫醒值班人,以及用户下一步该干什么。线上真正昂贵的故障,往往不是冷门状态码,而是常见状态码被用在了错误的失败类型上。
五个类别
| 类别 | 含义 | 谁来处理 | 常见误用 |
|---|---|---|---|
| 1xx | 信息 | 协议层 | 把 101 协议升级当成普通 JSON 响应 |
| 2xx | 成功 | 无需处理 | 返回 200 加错误正文 |
| 3xx | 重定向 | 客户端跟随或缓存 | 301 被浏览器长期缓存,迁移回滚后仍跳转 |
| 4xx | 客户端错误 | 客户端修改请求 | 缺少 Token 却返回 403 |
| 5xx | 服务端错误 | 服务端修复,客户端可重试 | 表单校验失败却返回 500 |
先看第一位数字。4xx 不是服务端 bug,5xx 也不是客户端 bug。
类别是路由信号,不是严重程度排名。代理、CDN 和客户端库在解析错误正文之前,就已经按类别做了决定:缓存可能把 200 存一年,重试中间件可能把 503 再发三次,统计系统可能把 404 当噪声丢掉。把校验失败写成 500,会同时造成两种伤害:客户端以为稍后重试就能成功,值班工程师却被一个用户输入问题叫醒。
2xx 也不只有 200。创建资源用 201 并带 Location;异步任务已受理但未完成用 202;成功且没有正文用 204;范围请求用 206。浏览器通常不关心这些区别,SDK 和工作流引擎却很依赖:完成和排队对应完全不同的下一步。
400 和 422
400 Bad Request:服务端无法处理请求,原因是语法错误、缺少必要参数或内容类型不正确。
422 Unprocessable Content:请求语法正确,但语义不合法,例如数量为负、结束日期早于开始日期、值超出允许范围。
// 400:JSON 格式错误
{ "quantity": }
// 422:JSON 合法,但值不合法
{ "quantity": -2 }
有些 API 统一用 400。只要错误正文解释清楚,也可以接受;但 422 能让客户端明确知道“请求解析成功,是值需要修改”。
建议把最小复现写进契约测试:发送 { "quantity": } 断言 400,再发送 { "quantity": -2 } 断言 422。如果两者都返回 400,客户端无法判断是改一个 JSON 拼写就够了,还是必须改业务取值;如果两者都返回 422,那么被截断的正文或错误的 Content-Type 会被误报成校验问题,前端表单甚至会去高亮一个根本不存在的字段。
有用的 422 会指出字段和约束,而不是把整个请求回显一遍:{"error":{"code":"validation_error","field":"quantity","message":"必须大于等于 1"}}。相邻状态码也应各有分支:415 用于正文格式完全不对,406 用于内容协商失败,413 用于在解析前就因体积被拒。把这些都塞进 400,等于把“该修什么”藏了起来。
422 也有不适合的场景。只认 400 的老式 SOAP/XML 网关、很难定义“语法正确”的表单编码接口,以及 GraphQL 这种约定用 200 加 errors 数组表达业务错误的协议,都不该硬套 422。按接口类型选择一套约定并写进文档,比悄悄混用更可靠。
401 和 403
401 Unauthorized:缺少认证或认证无效。客户端应该获取凭据后重试。响应应包含 WWW-Authenticate 头说明认证方式。
403 Forbidden:客户端已认证,但没有权限。用同样的凭据重试不会成功。
常见错误是为了隐藏资源是否存在,对缺少 Token 的请求返回 403。这可以作为安全策略,但会让客户端行为变得模糊,必须写进文档。
响应头也要精确。401 应带 WWW-Authenticate: Bearer realm="api", error="invalid_token",SDK 靠它判断该刷新、跳登录还是直接失败;403 不应带这个头,因为凭据已经被接受,问题在授权。该刷新时不给 401,或者 Token 过期后返回 403,都会导致刷新死循环:客户端要么永远不刷新,要么无限刷新。
一个真实故障:接口对过期 access token 返回 403。前端看到 403,认定用户没权限,提示“无权访问”,从不调用刷新接口。于是用户几乎每隔一个 Token 生命周期就被锁死一次。服务端只需在鉴权中间件里检查 exp,过期时返回 401 并带 token_expired 错误码,客户端就能区分“该刷新”和“凭据错误”。
反过来,用 404 掩盖其他租户的资源,是合理的安全选择,但会把歧义转移到监控:面板上的 404 尖峰其实是授权拒绝。服务端要记录真实原因,对外保持与真实 404 相同的正文和响应时间,并按内部原因告警,而不是按公开状态码告警。
404 和 410
404 Not Found:服务端找不到资源,可能是临时的,也可能是故意隐藏。
410 Gone:资源曾经存在,已被永久删除,告诉客户端和爬虫不要再请求。
410 要谨慎使用,只有确认资源不会恢复时才用。大多数 API 用 404 就够了。
边界情况决定选择。软删除的记录通常应该返回 404,而不是 410,因为删除可能被恢复,客户端不该永久放弃这个 URL;410 更适合已下线且生命周期有明确终点的接口。两种状态码默认都可缓存,所以对“可能回来”的资源返回 410 时,要显式设置 Cache-Control: no-store,否则资源恢复后客户端仍可能长期认为它不存在。
迁移期间尤其要小心 410。爬虫会很快丢弃 URL,内容恢复后需要从头积累权重。只要资源还有可能回来,就用 404,或者 301 到后继地址,并在迁移稳定前保留旧 URL 的站点地图记录。
409 冲突
409 Conflict 表示请求和资源当前状态冲突,例如:
- 创建用户时邮箱已存在。
- 用过期版本号更新记录。
- 删除仍有依赖数据的资源。
响应里要给出足够的信息让客户端处理:
{
"error": "conflict",
"field": "email",
"message": "该邮箱已注册。"
}
409 描述的是状态冲突,不是语法错误。响应要让客户端不用猜就能解决冲突:给出冲突字段、当前版本,或已有资源的链接。乐观锁场景应把 409 和 ETag、If-Match 配合使用,版本检查在 HTTP 层失败时返回 412 Precondition Failed。很多团队两种都用 409,关键是要明确客户端该按哪个分支处理。
最小复现:两个客户端都读到版本 7,然后同时写入。第一个成功,第二个绝不能静默覆盖。服务端返回 {"error":{"code":"version_conflict","currentVersion":8}},客户端重新读取、合并,再用新版本重试。如果第二次写入返回 200,就发生了一次无感知的更新丢失,日志里连错误都不会有。
幂等创建还有一条相关规则:带 Idempotency-Key 的 POST 重试应返回第一次的结果,而不是新的 409;同一个键配了不同正文时,才返回 409 或 422 并给出明确错误码,让客户端知道这不是一次合法的重放。
429 限流
429 Too Many Requests 表示客户端超过限流。应包含 Retry-After:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
客户端应该使用带抖动的指数退避,并遵守 Retry-After。用 403 或 503 表示限流会让重试行为变得不可预测。
Retry-After 有两种合法写法:秒数(Retry-After: 30)或 HTTP 日期(Retry-After: Wed, 11 Nov 2026 10:00:00 GMT)。只解析其中一种的客户端,要么永远等下去,要么完全忽略提示。服务端估不出等待时间时,宁可不带这个头,也不要把值写成 0,那会立刻触发重试风暴。
能给出限流元数据时就给出:RateLimit-Limit、RateLimit-Remaining、RateLimit-Reset,或者旧式的 X-RateLimit-*。行为良好的客户端会在被拒之前主动降速。常见故障是移动端捕获 429 后立刻重试三次,结果换来更长的封禁。带完整抖动的指数退避——在 0 到 min(cap, base * 2^attempt) 之间取随机等待——能避免故障恢复时成千上万个客户端同时重试。
429 也有用错的时候。因为容量原因只限制单条连接或单个接口时,503 加 Retry-After 更准确;请求被 WAF 按策略拒绝时,那是 403。两者的区别决定了客户端应该等待,还是修改请求。
500、502、503、504
这些都是服务端问题,但含义不同:
| 状态码 | 含义 | 典型原因 |
|---|---|---|
| 500 | 内部服务器错误 | 应用未捕获异常 |
| 502 | 网关错误 | 上游返回无效响应 |
| 503 | 服务不可用 | 过载或维护 |
| 504 | 网关超时 | 上游未在超时内响应 |
排查方向不同:
- 500:看应用日志和堆栈。
- 502:看代理、负载均衡和上游健康状态。
- 503:看容量、发布状态和维护开关。
- 504:看上游延迟、超时和连接池。
502 或 504 往往意味着应用根本没收到请求。不要一上来就改业务代码。
日志里的请求 ID 和耗时能快速区分这几种情况。如果客户端拿到的请求 ID 在应用日志里完全查不到,请求死在了代理层或根本没到达,应从 502/504 查起;如果日志显示处理器已经开始但一直没结束,重点看下游超时或工作线程阻塞;如果处理器留下了堆栈,那才是真的 500。
一个具体故障:CDN 在 30 秒时返回 504,源站却继续处理,45 秒后完成了支付。客户端重试,用户被扣款两次。网关超时不会取消源站工作,所以任何可能超时的非幂等操作都要有幂等键或状态查询接口。看到 504 就无脑重试同样危险。
代理生成的 502、503 经常返回 HTML 错误页,而不是你的 JSON 错误结构。只认 JSON 的客户端会抛解析异常,连状态码上下文都丢了。要测试经过代理的路径,而不只是应用本身,并让网关对 API 路由统一返回 JSON。
3xx 和重定向
301、308 是永久重定向;302、303、307 是临时重定向。API 中频繁出现重定向通常是配置问题。如果每个请求都要跟随重定向,应该修基础 URL。
注意 307 和 308 会保留方法和正文,303 会把方法改成 GET。一个把 POST 变成 GET 的重定向可能静默丢数据。
301 会被浏览器缓存得很久,有时超过响应头声明的期限,所以只用于不可逆的永久迁移;非 GET 接口的永久重定向,308 更安全,因为它保留方法。POST 之后让客户端去 GET 结果页,正确状态码是 303。
跨域重定向会让大多数 HTTP 客户端丢掉 Authorization 头。API 基础地址跳到另一个主机后,即使 Token 有效,调用也会变成 401;这时该修基础地址,而不是鉴权代码。客户端不缓存重定向时,每个请求还会多一次往返,所以 API 里的重定向通常是配置问题,不是功能。
重试语义
只在操作可以安全重复时重试:
GET、HEAD、PUT、DELETE按定义是幂等的。POST默认不幂等,除非使用幂等键。
可重试状态码:
408 Request Timeout
425 Too Early
429 Too Many Requests
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
使用带抖动的指数退避,限制最大尝试次数,并遵守 Retry-After。非幂等请求使用幂等键,避免重试造成重复创建。
重试预算比状态码清单更重要。一个服务把每个 503 都重试三次,等于在故障期间把负载放大四倍,而这正是后端最扛不住的时候。要同时限制单请求和单客户端的重试次数,并加入抖动,避免重试同步。幂等写入优先用带完整表示的 PUT 或条件请求;POST 则发送 Idempotency-Key: <uuid>,服务端至少在整个重试窗口内保存键和响应。
425 Too Early 服务于重放保护,不是通用重试信号:它表示请求可能已经被重放,客户端应在 TLS 握手或 early data 窗口稳定后重试。没弄懂原因之前,不要把它加进通用重试列表。
错误正文设计
有用的错误正文包含:
- 稳定的机器可读错误码。
- 人类可读的说明。
- 出错的字段或参数。
- 请求 ID,便于支持排查。
- 可选的文档链接。
{
"error": {
"code": "validation_error",
"message": "结束日期必须晚于开始日期。",
"field": "endDate",
"requestId": "req_01JCH6X8Y4Q2V7M3N9K5R1T6B8"
}
}
不要在响应里放堆栈、密钥或内部主机名。
统一媒体类型。RFC 9457 定义了 application/problem+json,字段包括 type、title、status、detail 和 instance;直接采用它,客户端和工具链就有稳定结构,不必自己发明一套。如果保留自定义信封,机器可读错误码要在版本间保持稳定,因为客户端会对它做分支;message 则应当视为可能变化、可能本地化的人类可读文本。
请求 ID 要同时出现在正文和 X-Request-ID 响应头里。用户报障时,支持人员可以用它搜索日志,而不是让用户截图。错误正文经常被第三方日志系统和浏览器开发者工具记录,不适合放任何敏感内容。
排查顺序
- 看状态码类别。
- 看响应正文和响应头。
- 用 curl 或 HTTP 请求工作台 复现。
- 4xx 检查请求:方法、URL、头、正文、认证。
- 5xx 检查服务端日志和上游健康状态。
- 检查代理、CDN 或网关是否修改了响应。
- 必要时检查
Retry-After、WWW-Authenticate和 CORS 头。
HTTP 状态码查询工具 提供标准含义和常见原因。HTTP 头解析工具 可以查看经常解释故障原因的响应头。
在看状态码之前,先确认自己拿到的是正确的响应。浏览器开发者工具对 DNS、TLS 和 CORS 失败显示状态 0,此时根本没有 HTTP 响应,也就没有状态码。curl -v 能看出是代理应答、TLS 是否成功、重定向是否被跟随。耗时也能提供线索:很快返回的 502 多半是上游连接被拒,很慢的 504 是超时,两毫秒就返回的 500 更像路由或中间件 bug。
任何状态码处理改动,都应过一遍检查清单:客户端只在 401 时刷新、不在 403 时刷新;重试中间件只处理清单内的状态码并遵守 Retry-After;监控把 4xx 和 5xx 分开;缓存不会误存错误响应;错误正文经过代理后仍能按 JSON 解析;请求 ID 能在服务端日志里查到。
一致比完美更重要
不同 API 对 400/422、401/403 的划分不完全一样。真正重要的是内部一致、文档清楚。客户端应该能从失败类型预测状态码类别,运维应该能判断重试是否有用。
选一套约定,写进文档,并让错误正文有用。状态码是第一个信号,正文才是修复的起点。
用一张决策矩阵让新接口保持一致:语法错误用 400;语法正确但取值非法用 422;缺少或无效凭据用 401;已认证但无权限用 403;状态冲突用 409;触发限流用 429;未预期的服务端失败用 500;上游响应无效用 502;过载或维护用 503;上游超时用 504。把矩阵放进 API 风格指南,并为每个分支写契约测试,因为约定往往是一个处理器一个处理器地走样的。
当 REST 纯粹性和客户端行为冲突时,优先照顾客户端。只有一个内部客户端的接口,用点不常见的状态码还能接受;一旦出现第二个客户端、代理或重试层,歧义就会变贵。GraphQL 和部分 RPC 框架有意用 200 表达业务错误,那是另一套契约,不是 REST 接口也可以 200 加错误的理由。每个 API 面选一种模型,写清楚,并且测试失败路径,而不只是 happy path。
常见问题
400 和 422 有什么区别?
400 Bad Request 表示服务端无法理解请求语法或缺少必要参数。422 Unprocessable Content 表示语法正确,但内容语义不合法,例如数量为负、结束日期早于开始日期。
什么时候返回 401,什么时候返回 403?
缺少认证或认证无效时返回 401,客户端应该去获取凭据。已经认证但没有权限时返回 403。不要用 403 来掩盖缺少认证。
资源不存在时一定要返回 404 吗?
通常是的。资源曾经存在但已被永久删除时可以用 410 Gone;不希望暴露资源是否存在时用 404。同一个 API 内部要保持一致。
哪些状态码应该重试?
408、425、429、500、502、503、504 可以配合指数退避和抖动重试。非幂等请求不要盲目重试;服务端提供 Retry-After 时要遵守。
为什么返回 200 加错误正文是个问题?
客户端、代理、监控和重试逻辑都依赖状态码判断结果。200 会让除业务代码之外的所有层都认为成功,从而隐藏故障并破坏可观测性。