作为后端开发,我几乎每天都会在日志里见到一堆 4xx 状态码,但发现很多同事对它们的理解一直停留在“大概就是客户端错了”这个层面。尤其是每次联调前端报“你又给我返回 400 了”,后端一查其实是不带参数名提示的 JSON 解析失败,根本不知道从哪下手;或者前端明明登录了,接口却一直 403,两边来回拉扯半天。HTTP 状态码里的客户端错误(4xx),本质上就是服务器在用 HTTP 协议告诉调用方“你这次请求哪里不对”,读懂了它们,整个联调和排障效率会提高非常多。
这是 HTTP 状态码系列的第四篇,前一篇已经把 400、401、403、404 这几个高频状态码讲得比较透了,这一篇集中把 4xx 里面剩下的“硬骨头”全部啃完:405、406、408、409、410、412、413、414、415、416、417、428、429、431,同时我会把 401、403、407 这三兄弟的边界也再撕开一次,顺便结合之前项目里真实踩过的坑来讲解。适合后端、前端、运维、以及所有要写接口文档的人,争取看完之后,再遇到 4xx 报错都能直接定位到原因。
1. 请求本身没写对:从 400 到 417 的一串“格式问题”
1.1 400 Bad Request:框架替你挡下的“格式炸弹”
400 在语义上是“服务器无法理解请求的语法”,翻译成人话就是:服务端虽然收到了你的 HTTP 请求,但报文本身没法按约定解析。实际工作中 400 最常出现的三种场景,我逐个拆一下。
第一种是 JSON 反序列化失败。比如接口要求接收 {"name": "张三", "age": 30},结果调用方传了 {"name": "张三", "age": "三十"},服务端在把字符串转成 int 的时候直接抛异常,框架默认返回 400。Spring Boot 里遇到的 HttpMessageNotReadableException、FastAPI 里的 RequestValidationError,基本都是这个套路。这时候响应体里通常会带一句“JSON parse error”,但对排障真正有用的是它后面紧跟的 field 和 reason。很多团队只看状态码不看响应体,这是我最想纠正的毛病。
第二种是参数绑定失败。这种经常出在 GET 请求的 query 参数上,接口要求 ?page=1&size=10,结果调用方传了 ?page=abc,后端转 Integer 失败,也是 400。第三种是请求体在传输层就带着非法字符,比如没有正确做 URL 编码的非 ASCII 字符、Content-Type 和实际 body 格式不匹配,网关或应用容器直接拒绝。
在旧项目里我还遇到过一种情况:前端把表单里所有字段名从 snake_case 改成了 camelCase,后端用的是老接口没做兼容,结果整个页面全是 400。后端日志里赫然写着“JSON parse error: Unrecognized field”,但前端页面上只有一个笼统的“系统繁忙”。这件事给我的教训是:后端在返回 400 的时候,响应体里必须写清楚“哪个字段、什么格式、期望的值是什么”,而不是只甩一个数字。比如说:
json复制{
"code": 40001,
"message": "参数校验失败:age 字段必须是 18-60 之间的整数",
"request_id": "c1a2b3d4e5f6",
"detail": {
"field": "age",
"reason": "expected integer, got string"
}
}
还有一个值得注意的点:很多 AI 模型服务、第三方开放平台返回的 400,响应体里已经把原因写得非常明确了。我见过一个调用大模型接口的报错,上游返回 400,里面直接写着“the reasoning_content in the thinking mode must be passed back to the api.”,意思是开启思考模式后,需要把上下文里的 reasoning_content 一起回传给 API,否则请求不合法。这个例子说明:遇到 400 先看响应体,它已经把钥匙递到你手里了,不要一上来就怀疑网关或网络。
1.2 405 Method Not Allowed:路由正确,方法不对
405 的语义是“请求方法不被允许”。最常见的场景是你明明访问了一个存在的接口路径,但用的 HTTP 方法不对。例如服务端只实现了 GET /api/v1/users,你偏偏发了一个 DELETE /api/v1/users,路由匹配上了,但方法不匹配,服务端返回 405。
这里有一个非常容易被忽视的细节:规范要求 405 响应必须带 Allow 头,告诉客户端这个资源支持哪些方法。比如:
http复制HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, OPTIONS
对排障的工程师来说,Allow 头比状态码本身信息量大得多。你可以直接看出是应该改成 GET 还是 POST,而不是对着代码猜。用 curl 测一下就能看到:
bash复制curl -i -X DELETE http://localhost:8080/api/v1/users
如果响应里出现 Allow: GET, HEAD, OPTIONS,那答案已经写在响应头里了。
还有一个隐蔽的坑和跨域有关。浏览器发跨域请求时,如果请求不是简单请求,会先发一个 OPTIONS 预检请求。很多后端框架没有显式处理 OPTIONS,或者接口方法上只写了 @PostMapping,结果预检请求直接命中“无方法匹配”的逻辑,返回 405,前端控制台只会显示“CORS error”。排查这类问题时,先用 curl -i -X OPTIONS 复现,看看是不是 405,如果确实是渠道被拦,通常需要在网关层或拦截器里统一放行 OPTIONS。
1.3 内容协商失败:406 与 415 的左右手
406 Not Acceptable 和 415 Unsupported Media Type 经常被弄混,其实一个管“输出”一个管“输入”。
406 发生在内容协商阶段,核心看 Accept 请求头。客户端在 Accept 里声明了自己能接受哪些响应格式,比如 Accept: text/html,但服务端这个接口只输出 application/json,两边谈不拢,服务器只能回 406,意思是“你想要的我给不了”。实际工作中,406 不像 404 那么常见,但一旦遇到,排查方向非常明确:看 Accept,看服务端能产生什么 Content-Type。Spring MVC 里面如果配置了 ContentNegotiationManager,对这类情况通常会有默认行为,但不同版本表现略有差异,测试的时候要注意。
415 则相反,它关心的是“你发的 body 我解析不了”。最经典的场景就是调试接口时写 curl,curl -d '{"name":"test"}' http://localhost:8080/api,但忘记加 Content-Type: application/json 头。此时服务端看到的是 text/plain 的请求体,但接口方法签名上写的是 @RequestBody User,框架不知道如何把纯文本解析成对象,于是返回 415。同理,上传文件时忘记把 Content-Type 设成 multipart/form-data,或者 multipart 的 boundary 写错,也会得到 415。
这里我建议所有开发者养成一个习惯:在 curl 命令里把 -H "Content-Type: application/json" 写得明明白白,不要依赖 curl 自动推断。
bash复制curl -X POST http://localhost:8080/api/user \
-H "Content-Type: application/json" \
-d '{"name": "zhangsan", "age": 25}'
1.4 请求还没传完:408、411、417
408 Request Timeout 的语义是“服务器在等待客户端发送请求的剩余部分时超时了”,也就是说服务端等一个完整的请求等得太久,直接断开连接。它和 504 的区别我曾经搞混过:408 是服务端还没收完请求就放弃等待;504 是网关已经把请求转发给上游,但上游在限定时间内没有响应。一个是“请求没到齐”,一个是“响应没来”。
有一种 408 的变体非常隐蔽:客户端复用了 HTTP 长连接(keep-alive),但上一个请求的 body 没有按 Content-Length 发完就中断了,服务端在下一次读操作时检测到连接异常,直接判定超时。这就引出热词里“http 连接复用”相关的经典连锁故障:某个服务用连接池发请求,池里某个连接被服务端关闭,但客户端不知道,下一个请求复用这个坏连接,服务端可能在读请求行时收到空数据就断开,客户端侧表现为“Connection reset”或“EOF”,偶尔还会被误报成 408。排查这个问题的关键,是看看客户端是否正确处理了连接重试。
411 Length Required 指的是服务端要求请求必须带 Content-Length 头,但客户端没给。正常情况下,HTTP/1.1 客户端在发送 body 时会自动带上 Content-Length,只有在使用 Transfer-Encoding: chunked 时才会省略。有些老旧的网关或代理服务器对 chunked 支持不好,就会直接甩 411。如果你在内网环境里遇到老代理、老系统之间的调用,这个状态码还是有一定出现概率的。
417 Expectation Failed 和 Expect: 100-continue 是配套的。它的机制是:客户端准备发送一个大 body,但先发送请求头,同时带 Expect: 100-continue,询问服务器“你愿意接收吗?”服务器如果看看头就知道这请求有问题,直接回 417,客户端就不用白费流量把 body 全发出来。这个机制在大文件上传场景里尤其有用,只不过现代框架很多默认不开启 100-continue 链路,导致 417 比较少见,但一旦出现,基本就是反向代理层做了拦截。
1.5 塞得太满:413、414、431
413 Payload Too Large,请求体太大。最常见的场景就是上传文件超过 Nginx 的 client_max_body_size 配置,或超过 Tomcat 的 maxPostSize、Spring 的 spring.servlet.multipart.max-file-size。我有一个真实的教训:公司之前的文件上传接口,本地开发环境能传 50MB 的文件,但一到测试环境就报 413。最后查出来是 Nginx 层配置的 client_max_body_size 10m 限制了。这个问题的麻烦点在于它往往是多层的链路:浏览器 -> SLB/API网关 -> Nginx -> Tomcat -> 应用,任何一层都有体积限制,少调一层都不行。排障时我习惯先把应用层的限制调大,再逐层往上查,用 curl -i -X POST --data-binary @bigfile.bin http://target 复现。
414 URI Too Long 是 URL 太长。它的典型来源是前端把查询条件、筛选参数全部塞进 GET 请求的查询字符串里,或者埋点系统把完整事件体编码后拼到 URL 上。服务端对请求行长度有默认上限,比如 Nginx 默认 large_client_header_buffers 是 8k,超过就返回 414。修复方案有两个层面:业务层建议把这类场景从 GET 改成 POST,把参数放进 body;服务器层可以适当调大 large_client_header_buffers,但我不建议调得过于夸张,因为过长的 URL 本身就是一种入侵信号,保持在 8k 到 16k 以内足够了。
431 Request Header Fields Too Large 是 414 的兄弟,不过是请求头太大。典型的元凶是:Cookie 里塞了大量数据、JWT 长度过长放在 Authorization 头里、或者客户端把本地缓存的上下文一股脑放进自定义 Header。遇到这个状态码,不要急着调服务器参数,先检查是不是 Header 设计本身有问题,该压缩压缩,改走 body 就走 body。Header 扩容也只是治标不治本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 资源的状态变了:409、410、412、428、429
2.1 409 Conflict:并发与重复资源
409 的含义是“请求与资源当前状态存在冲突”。它本身就带着“再试一次可能成功,但你需要先调整状态”的暗示,非常适合表达并发控制和重复创建。
举一个电商库存扣减的例子。假设库存表有一个 version 字段,后端更新库存的 SQL 写成:
sql复制UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = ? AND version = ?
两个用户同时下单,都读到 version=1,第一个请求执行成功,version 变成 2;第二个请求带着 version=1 去更新,SQL 影响行数为 0。此时后端就可以返回 409 Conflict,前端收到后提示“商品库存已经被别人抢先更新,请刷新后重试”,而不是让用户一脸懵地面对“系统错误”。
同样的思路也适用于文档编辑、配置修改等场景,在“乐观锁 + 版本号 + 409”这套组合下,数据一致性能得到很好的保障。另一种 409 的典型场景是重复创建资源。比如注册接口要求用户名唯一,用户提交了一个已经存在的用户名,服务端返回 409 比返回 400 更精确,因为它告诉调用方“你的请求格式没问题,是资源已经存在了”。
这里要特别区分 409 和 412。我的理解是:412 是“请求还没被实际处理,前置条件已经不满足了”,通常在读取请求头时就能判断;409 是“请求在处理过程中检测到业务层面的冲突”,通常需要读取或修改资源状态后才能判断。排障时不要看到“冲突”就往 409 上靠,要问一句:这个冲突是在哪一层发现的?是条件头不匹配,还是业务数据冲突?
2.2 410 Gone:带着明确“死讯”的 404
410 Gone 表示“这个资源曾经存在,但现在永久不可用了,而且不会恢复”。它和 404 的区别非常实用:404 是“服务器不知道这个地址有什么”,可能是路径写错、可能是从未存在过;410 是“服务器明确知道这个地址曾经有东西,但现在已经下线了”。
实际应用场景包括:老版本 App 还在请求已经下线的接口、活动页面活动结束后的回调地址、以及已经废弃的 Webhook。对搜索引擎来说,410 比 404 更能加快从索引中移除页面的速度,因为搜索引擎能明确理解“不会恢复”。我在项目里就在活动 H5 页面结束后返回过 410,前端一旦收到这个状态码,直接跳转到“活动已结束”的说明页,而不是显示千篇一律的“页面不存在”,这在产品体验上是两种感觉。
2.3 条件请求与 412/428:用 ETag 做并发控制
412 Precondition Failed 的语义是“请求头里的前置条件不成立”。最常见的条件头是 If-Match、If-None-Match、If-Modified-Since、If-Unmodified-Since。
拿 If-Match 举个例子:客户端读取了一份文档,服务端返回的响应头里带着 ETag: "abc123"。客户端修改后提交更新时,带上 If-Match: "abc123"。服务端在处理前先比较当前文档的 ETag 是不是还是 "abc123",如果不是,说明文档已经被别人改过了,此时返回 412。这套机制和 409 的乐观锁思路异曲同工,但更偏协议化、更标准。
还有一种扩展状态码 428 Precondition Required,它来自 RFC 6585,比 412 更进一步,表示“服务器要求这个请求必须携带条件头,但你没有带”。举个例子:如果服务端规定所有写操作都必须带 If-Match 头才能执行,而客户端只是简单地 POST 了一个更新,没有带任何条件头,服务端可以返回 428,相当于在告诉客户端:“你没有按规则带版本信息,请带上再来。”
我在设计接口时,会把这两者结合起来:写操作强制要求带 If-Match,没带回 428,带了但不匹配回 412。这样客户端的行为会被协议约束得非常清晰,比后端写一堆 if 判断判断“是否被修改”要干净得多。
2.4 429 Too Many Requests:限流的正确姿势
429 是“请求太频繁,服务端有限流策略,你被暂时禁止了”。它非常关键的一个响应头是 Retry-After,表示调用方需要等多久之后才能再次请求。Retry-After 的值可以是秒数,也可以是 HTTP 日期格式,例如:
http复制HTTP/1.1 429 Too Many Requests
Retry-After: 120
服务端做限流时,我强烈建议把 429 和 403 区分开。403 更偏向“你没有权限”,而 429 的语义是“你暂时被限流了,等一会儿再来”。如果一个接口被恶意刷量,限流网关直接返回 429 并带上 Retry-After,是标准的业界做法。
对应的客户端策略是“指数退避”,即第一次失败后等 1 秒重试、第二次等 2 秒、第三次等 4 秒,同时要控制最大重试次数,避免把服务端打得更惨。如果调用方是前端浏览器,还要考虑防止用户连续点击,本地做一个简单的请求队列或防抖。我见过不少团队在后端已经限流的情况下,前端还是疯狂发请求,结果本来只是临时限流,硬生生被打成了全站故障。限流不只是后端的事,前端也要有点配合意识。
3. 身份、权限与代理:401、403、407 三兄弟的边界
3.1 401 Unauthorized:是“你是谁”没证明
401 虽然在单词上叫 Unauthorized,但它的正确定义是“未认证”,也就是服务端不知道你是谁,或者你提供的凭证无效。这里我见过无数团队把“未登录”和“没有权限”混在一起,让前端不知道怎么处理。
调试 401 时,第一反应永远是看 WWW-Authenticate 响应头。它告诉客户端当前接口用的是哪种认证机制,常见的包括 Basic、Bearer、Digest:
http复制HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token"
如果是 Bearer Token 认证,说明客户端需要在 Authorization: Bearer <token> 里带上有效令牌;如果令牌过期、被撤销、签名不对,服务端都会回 401,同时可以在响应体里给出更细的 error 信息。
一个实战案例是 GitLab 用 HTTP 方式克隆仓库时经常遇到的报错:remote: HTTP Basic: Access denied,然后 fatal: Authentication failed。从状态码角度,这本质上是仓库服务返回了 401,但 Git 客户端把它描述成了 access denied。导致这个问题的常见原因有三个:一是账号密码本身就是错的;二是开了两步验证(2FA),但用的还是账号密码而不是 Personal Access Token;三是用户名或仓库路径写错了。排查顺序我建议是:先确认用户名密码,再确认是否在 GitLab 里生成了 Personal Access Token 并把 remote 地址中的密码替换成 token,最后检查 URL 是否带了奇怪的路径前缀。
3.2 403 Forbidden:证明了你也不许进
403 的语义是“服务端知道你是谁,但你被明确禁止访问这个资源”。它可以细分为很多原因:角色权限不够、资源所有者不是你、IP 在黑白名单里、请求被 WAF 拦截等。
有个经典测试可以快速判断一个系统对 401 和 403 的处理是否正确:用浏览器打开一个登录后才能访问的页面,如果没登录,系统返回 401,说明它告诉客户端“请先登录”;如果返回 403,说明它告诉客户端“你访问不了,但原因不明”。前端的正确交互逻辑应该是:401 跳转到登录页,403 停留在当前页并弹出“无权限”的提示。如果后端把“未登录”也返回 403,前端就无法区分该不该跳登录页,只能所有错误都当“系统异常”,体验非常糟。
还有一种 403 容易被后端自己忽略:文件系统权限不够。比如 IIS 里的 500.19 错误,响应头看起来是 500,但根因往往和 Web 站点目录的 NTFS 权限、或是请求映射配置有关系。所以遇到 403 系列报错,不要只盯着“HTTP 状态码为 403”,还要看它是不是应用层返回的,还是中间件在更早的时候拦截了。
3.3 407 Proxy Authentication Required:卡在企业代理这一关
407 是 401 的“代理版本”,意思是“服务器需要你先通过代理服务器的认证”。在企业内网、或者开发环境里配置了本地代理的情况下,这个状态码出现的频率比很多人想象得要高。它同样会带 Proxy-Authenticate 响应头,客户端需要在请求里增加 Proxy-Authorization 头才能通过代理。
我在排查一些下载工具、包管理工具连接失败时,发现一个常见链条:CondaHTTPError: HTTP 000 CONNECTION FAILED、Docker 拉镜像时“request canceled while waiting for connection”、curl 请求外部站点超时,表面上报错五花八门,最后全部指向同一个问题——本地或网络环境里的代理配置不对,或者代理需要认证但没有提供。此时检查的优先级是:先看系统环境变量 http_proxy、https_proxy、no_proxy,再看对应工具自己的配置文件里有没有代理设置,最后再看代理服务器本身是否还需要认证。
如果是私有代理且需要用户名密码,标准的写法是:
bash复制export http_proxy="http://user:password@proxy.example.com:8080"
export https_proxy="http://user:password@proxy.example.com:8080"
export no_proxy="localhost,127.0.0.1,192.168.0.0/16"
注意 no_proxy 一定要把内网地址排除掉,否则内网请求也被代理绕一圈,既慢又容易出问题。这类问题的根因往往不在应用层,而在网络层和代理层,排查时先把代理摘掉、直连测试,是最有效的定位手段。
4. 实战定位:从系统报错里读出真正的 4xx
4.1 Docker Registry 的 HTTP 与 HTTPS 协议混用
Docker 拉取镜像时,报错信息五花八门,其中两个特别容易误导人。
第一个是 Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: request canceled while waiting for connection。这个报错从文案上看像是“请求被取消”,但本质上不是 4xx 状态码问题,而是网络层没连上 registry。排查方向是:先 curl -v https://registry-1.docker.io/v2/ 看看能不能连上。如果直接返回 401,其实是正常的——Registry 的匿名流程就是先拿 401,再由客户端去 token 服务申请令牌。所以看到 401 不要慌,它在 Docker Registry 的认证流程里是“正常开局”,关键在于客户端会不会走后面的 token 流程。
第二个是拉取私有仓库时出现 http: server gave HTTP response to HTTPS client。这个报错翻译过来是:你的 Docker daemon 默认用 HTTPS 去访问一个实际只支持 HTTP 的 registry 服务。解决方案是在 /etc/docker/daemon.json 里把私有仓库地址加入不安全的 registry 列表:
json复制{
"insecure-registries": ["registry.example.com:5000"]
}
然后重启 Docker 服务。这个问题看起来像是地址写错,实际上是 HTTP 和 HTTPS 协议不一致导致的原生报错,在很多基础文档里不会讲得特别细,但实际工作中非常常见。
4.2 CondaHTTPError 里的 HTTP 000 到底代表什么
Conda 在换源、安装包时如果网络出问题,报错往往长这样:
code复制CondaHTTPError: HTTP 000 CONNECTION FAILED for url <http://mirrors.bfsu.edu.cn/anaconda/cloud/conda-forge/win-64/current_repodata.json>
注意这里的 000 不是真正的 HTTP 状态码,而是 Conda 客户端自定义编码:只要 TCP 连接无法建立、DNS 解析失败、代理连不上、连接超时,客户端统一显示成 000。所以排障时不要搜“HTTP 000 是什么状态码”,应该把它当作“网络层失败”来查。
我的排查顺序是:先直接 curl 一下报错里的 URL,看看通不通;然后检查 http_proxy 环境变量是不是指向了一个不存在的代理;再用 ping 或 nslookup 确认 DNS 解析没问题;最后看镜像源地址是不是已经失效,需要换成当前可用的源。很多情况下,Conda 的 000 和代码本身没关系,纯粹是网络环境发生了变化。
4.3 网关包装类错误:Feign 的 500 里藏着 4xx
微服务架构里,Feign 是一个高频组件。它有一类非常典型的“状态码迷惑问题”:上游服务返回了 400,但 Feign 默认把一切非 2xx 响应包装成 FeignException,到了调用方眼里可能又经过一层统一异常处理,最后包装成 500 返回给前端。于是前端看到的是 500 内部错误,但真正的根因是上游某个入参不合法。
排查这类问题不能只看表面状态码,要深入异常链和日志,找到原始 response。这就是为什么我建议在微服务网关或统一异常处理里,把 4xx 和 5xx 分开记录日志,并且在响应体里塞 request_id。如果 Feign 调用发生异常,自定义一个 ErrorDecoder,把上游 4xx 的 status 和 body 原样透传,而不是吞成一个笼统的 500,这样“下游看到一个 500 然后在里面翻半天 causes”的情况会少很多。
这类问题的通用心法就是:状态码只是线索,不是结论。如果错误被层层包装,务必从最内层的 cause 开始看,而不是只看最外层的 500。
4.4 系统化定位 4xx 的标准姿势
处理 4xx 类问题,我自己的排障步骤基本是固定的,这里直接分享一套可以照着做的方法:
第一步,复现并查看完整响应。别用浏览器隐身模式猜,直接用 curl 带上方法和请求头:
bash复制curl -i -X POST http://target/api/user \
-H "Content-Type: application/json" \
-H "Authorization: Bearer xxx" \
-d '{"name": "test"}'
-i 会输出响应头,-v 会输出包括 TLS、DNS、代理在内的完整握手过程。如果 curl 能复现,问题基本就锁定在前端到服务端的链路上。
第二步,看响应体和响应头。响应体里的 message、detail、error 字段通常直接指出问题;响应头里的 WWW-Authenticate、Retry-After、Allow 则是解决相应状态码的关键。
第三步,看网关和访问日志。如果系统接入了 nginx、SLB、API 网关,它们的访问日志会记录实际返回给客户端的状态码和耗时,还能定位到具体是哪个下游返的 4xx。
第四步,确认请求是否真的到达了应用。如果服务端一套日志都没打,说明请求可能在网关、代理、防火墙就被拦截了。这一步能帮你区分“客户端请求有问题”还是“中间网络链路有问题”。
第五步,查看应用日志中的 traceId。如果应用有分布式追踪,直接按 traceId 捞整条调用链,看是参数在入口就被拒了,还是业务代码里抛出的业务异常被映射成了某个 4xx。
这套组合拳打下来,大部分 4xx 问题都能在一个小时内定位。我一直觉得,定位 4xx 其实比定位 5xx 要简单,因为 5xx 经常是服务端自己的问题,4xx 至少意味着服务器已经“读到”了请求,并给出了一个相对明确的答案。
5. 作为 API 设计者,怎么把 4xx 用成“联调说明书”
5.1 统一错误响应体结构
前文反复提到响应体的重要性,那到底应该返回什么结构?我的建议是所有 HTTP 接口的错误响应体使用统一的 JSON 结构,至少包含四个字段:
| 字段 | 类型 | 含义 |
|---|---|---|
code |
int | 业务错误码,应用层自定义 |
message |
string | 给调用方读的、可理解的错误描述 |
request_id |
string | 本次请求的唯一 ID,用于后端日志追踪 |
detail |
object | 补充字段,比如参数校验时哪个字段出错 |
这个结构有点像 Docker Registry 的错误规范,它使用 {"errors": [...]} 数组来承载一个或多个错误对象,每个对象都包含 code、message、detail 三层。设计错误响应时有个原则:机器可读 + 人可读。code 给程序用,message 给人看,request_id 给排查时用。
5.2 400/422/409 的选型建议
关于业务校验失败到底用 400 还是 422,业内一直有争论。我个人的意见是分三层:
- 请求报文本身解析不了,比如 JSON 格式错误、类型转换失败,用 400。
- 请求能解析,但字段语义不满足业务规则,比如姓名不能为空、年龄必须大于 18,用 422 或自定义业务码都可以,关键是团队内部保持一致。
- 请求本身没问题,但资源状态已变,比如库存被扣了、文档被改了,用 409。
这三者的差异其实就是“语法错误”和“语义错误”的差异。语法错误是协议和序列化层面的问题,语义错误是业务层面的问题。用 400 承载所有校验失败会让日志里的 400 数量爆炸,而且不好区分是“调用方写错了格式”还是“调用方业务没满足条件”。
5.3 前端统一拦截策略
状态码设计得再好,如果前端没有一个统一的拦截策略,最后还是各个页面自己处理错误,容易造成体验不一致。我在实际项目里用 axios 的响应拦截器做过一套处理逻辑,思路是:
javascript复制axios.interceptors.response.use(
(response) => response,
(error) => {
const status = error.response?.status;
const message = error.response?.data?.message || "请求失败";
if (status === 401) {
// token 失效或未登录,跳转登录页
window.location.href = "/login";
} else if (status === 403) {
// 无权限,提示但保持当前页面
notification.error({ message: "没有操作权限" });
} else if (status === 429) {
// 限流,提示稍后重试并做退避
const retryAfter = error.response?.headers?.["retry-after"];
notification.warning({
message: `请求过于频繁,请在 ${retryAfter || 60} 秒后重试`,
});
} else if (status >= 400 && status < 500) {
// 其他 4xx,读取响应体 message 作为错误提示
notification.error({ message });
} else {
// 5xx 和网络错误
notification.error({ message: "系统繁忙,请稍后重试" });
}
return Promise.reject(error);
}
);
这样一套拦截下来,后端只要保证错误响应体的 message 是可读的,前端就能直接展示,不需要每个页面单独写错误分支。
5.4 幂等性与条件请求在设计中的应用
接口的幂等性设计和 4xx 状态码其实是紧密相关的。以更新资源为例,一个比较规范的设计是:客户端先 GET /api/items/123,得到 ETag: "v1";然后 PUT /api/items/123 时带上 If-Match: "v1";如果服务端发现 ETag 已经变成 "v2",返回 412;如果服务端要求所有写操作都必须带 If-Match 但客户端没带,返回 428。这套设计比单纯依赖后端 version 字段更符合 HTTP 语义,也让客户端的行为被协议约束住。
对于创建资源的接口,如果同一个请求被重复提交,服务端可以通过唯一业务键判断出“资源已经存在”,返回 409,而不是傻傻地插入两条数据。这样前端在“用户连点两次提交按钮”的场景下,只需要拦截 409 并提示一句“您已提交过”,不需要额外设计一个全局的防重标志。把这些 4xx 用好了,接口文档都能少写好几页。
6. 一句话速查表与常见误区
6.1 本篇状态码速查表
| 状态码 | 语义 | 典型触发场景 | 排障切入点 |
|---|---|---|---|
| 405 Method Not Allowed | 方法不允许 | 路径对但方法不对 | 看 Allow 头 |
| 406 Not Acceptable | 响应格式不匹配 | Accept 头与服务端输出不一致 |
看 Accept 头 |
| 408 Request Timeout | 请求超时 | 请求 body 没发完 | 检查连接复用与超时配置 |
| 409 Conflict | 资源冲突 | 版本冲突、重复创建 | 看业务错误码 |
| 410 Gone | 资源永久下线 | 已废弃接口、活动结束 | 确认是否真的该下线 |
| 411 Length Required | 缺少 Content-Length | 服务端不支持 chunked | 检查代理与网关 |
| 412 Precondition Failed | 前置条件失败 | If-Match ETag 不匹配 |
看 ETag 与条件头 |
| 413 Payload Too Large | 请求体太大 | 上传大文件 | 逐层查上传上限 |
| 414 URI Too Long | URL 太长 | 参数拼在 GET 上 | 改用 POST |
| 415 Unsupported Media Type | 媒体类型不支持 | Content-Type 与 body 不匹配 |
检查 Content-Type |
| 416 Range Not Satisfiable | Range 范围不合法 | 断点续传、多线程下载越界 | 看 Content-Range |
| 417 Expectation Failed | Expect 头条件失败 | 100-continue 被拒绝 |
检查网关对 Expect 的支持 |
| 428 Precondition Required | 必须带条件头 | 写操作没有 If-Match |
检查请求条件头 |
| 429 Too Many Requests | 请求过于频繁 | 限流触发 | 看 Retry-After |
| 431 Header Fields Too Large | 请求头太大 | Cookie、JWT 过大 | 拆分 Header 数据 |
416 还有一个很实际的场景:视频点播或下载工具做断点续传时,如果客户端发的 Range: bytes=1000- 已经超过文件总大小,服务端返回 416,并带上 Content-Range: bytes */500,告诉客户端这个文件总共只有 500 字节。这个响应头非常关键,下载工具就是靠它重新校正起始位置。
6.2 联调时最常踩的 5 个 4xx 误区
第一个误区是把“权限不足”全部返回 401。未登录用 401,登录了但没权限用 403,这个区分直接决定了前端会不会错误地跳转登录页。
第二个误区是遇到 429 不用 Retry-After,或者客户端直接忽略它。限流接口如果客户端无限重试,只会让情况越来越糟,正确的做法是按 Retry-After 退避。
第三个误区是 400 的响应体不写任何信息。一个光秃秃的 400 对前端来说等于“天书”,除非它本身就是为了防扫描恶意请求,否则必须给出字段级错误提示。
第四个误区是用 404 隐藏一切不存在的资源,不管是接口路径错、还是数据不存在。安全问题想着“不暴露内部信息”,但在排障时一刀切的 404 会让问题极难定位。可以在安全要求不高的内部接口里把“路径不存在”和“数据不存在”分开返回。
第五个误区是前端把所有 4xx 都弹成“系统错误”。4xx 的本质是“客户端的错”,它本可以携带足够的信息让用户知道下一步该怎么做。只要错误响应体设计到位,前端完全可以把 400 的 message 直接展示出来,比如“密码长度必须大于 8 位”“邮箱格式不正确”,这才是 4xx 该有的用法。
我个人在维护线上系统时,最后还会做一个动作:在监控平台里把 4xx 按“客户端可修复”和“客户端不可修复”两个维度建独立看板。客户端可修复的 4xx,比如参数格式错误、未携带正确的 token,可以通过更好的文档和前端校验来降低,这类比例升高通常意味着某个新版本前端在上线;客户端不可修复的 4xx,比如 410 已下线接口、428 强制条件请求,更多意味着接口生命周期管理出了问题。把这两个趋势分开看,很多问题在发生用户投诉之前就能被发现。这套习惯帮我省掉了大量半夜被叫起来排查的精力,也让我真正理解了那句老话:HTTP 状态码不是错误,它是服务器和客户端之间最直接的对话语言。
