HTTP 状态码:联调时先看哪一层出错,不要先互相猜

HTTP 状态码最常被当成一张要背的号码表,真正有用的时候却是在联调现场。一个请求失败,前端说接口挂了,后端说参数不对,产品催着推进度,谁都觉得自己没错;这时候最值钱的信息,往往不是弹窗上的“请求失败”,而是 Network 面板里那串两三位数。

我对这件事印象最深的一次,是一个优惠券创建接口卡了整整一下午。控制台在报错,前后端在群里互相举证,谁也说服不了谁。直到有人提醒去看状态码,那条请求的 Status 一栏写着 422,后端返回的校验信息也随之暴露出来:effective_end_time 缺失。问题不是服务挂了,而是我漏传了字段。

从那之后我才真正把状态码当成“错误发生在哪一层”的信号来用,而不是当面试题来记。后面就按这个角度,把常见的 2xx、3xx、4xx、5xx 和联调时的排查路径重新捋一遍。

第一件事:状态码在哪看

先说最基本的:状态码不用等报错弹窗,随时能看。

浏览器这边,DevTools 的 Network 面板每个请求都有一列 Status,点进去还能看到完整的 Response Headers。我现在联调第一眼看的就是这一列,比任何口头描述都准——那个下午要是早点看,能省三个小时。

命令行一句也能看。curl -I 只发 HEAD 请求、只打响应头,第一行就是状态行:

1curl -I https://www.example.com
2# 第一行类似:HTTP/2 200
3# 后面跟着 content-type、cache-control 等响应头

还有个容易被忽略的点,我给一位实习生讲这事时他就中招过——fetch 拿到 4xx/5xx 时不会进 catch,它只在网络层断了(DNS 失败、断网、CORS 被拦)才 reject。在控制台试一下就明白:

1fetch('/this-path-does-not-exist').then(r => console.log(r.ok, r.status))
2// 输出:false 404
3// 注意:它走的是 then,不是 catch

r.ok 只有在状态码落在 200~299 时才是 true,所以拿 fetch 写请求层一定要自己判 res.ok,否则 404、500 会被当成「成功」一路往下传。axios 的行为不一样:默认非 2xx 会 reject 进错误分支,这个行为由 validateStatus 配置决定,默认是 status >= 200 && status < 300。有的团队想把 304 也当成功,就自己改这个配置:

1axios.create({
2  validateStatus: function (status) {
3    return (status >= 200 && status < 300) || status === 304
4  }
5})

我们那个「请求失败」的兜底提示之所以什么信息都没有,就是拦截器把所有非 2xx 一股脑规整成了同一句话——这是后话,文末的统一处理一节再算这笔账。

2xx:成功也分好几种

最常见是:

1200 OK

表示请求成功。

创建资源时可能返回:

1201 Created

有些删除接口会返回:

1204 No Content

204 没有响应体,前端不要再强行 JSON.parse

这个 204 的坑我今年踩过:删除接口返回 204,前端在拦截器里统一写了 response.data 拿数据,结果 axios 拿到的是空字符串,再往业务里传,某些代码对它 .xxx 直接报 Cannot read property of undefined。后来在拦截器里加了判断,204 直接当成功、不取 body:

1if (response.status === 204) {
2  return null
3}

204 还有个每天都在发生、但很多人没注意的出场场合:CORS 的预检请求。跨域的非简单请求(比如带自定义 header 的 POST)之前,浏览器会先发一个 OPTIONS 请求问服务器「我能不能这么发」,服务器答一个 204(或 200)带上 Access-Control-Allow-* 那几个头。Network 里那些 method 为 OPTIONS 的请求就是它。联调时遇到「明明接口是通的、跨域就是不行」,先看 OPTIONS 那条的状态码和响应头,别对着 POST 那条干瞪眼。

201 Created 在 RESTful 接口里语义更准——创建成功,通常还会在 Location 头里带上新资源的地址。不过国内很多接口图省事,创建也好删除也好一律返 200,body 里再放数据。这没绝对对错,但团队内得统一,不然前端写 if-else 判状态码会很乱。

还有个不那么常见但值得知道的:206 Partial Content,断点续传、视频分段加载会用到,配合 Range 请求头。做大文件下载或者视频播放器才会碰到,平时业务接口见不着。

3xx:重定向和缓存,两拨人

1301 Moved Permanently
2302 Found

表示重定向。

缓存相关常见:

1304 Not Modified

304 表示资源没变,浏览器可以继续用缓存。它不是错误。

调试静态资源时,如果看到 304,要知道这是协商缓存命中。

304 这块值得多说两句,因为它最容易让人误以为「缓存炸了」。流程是这样的:浏览器手里有缓存但不确定是不是最新,于是带上 If-None-Match(对应上次响应的 ETag)或 If-Modified-Since(对应 Last-Modified)去问服务器,服务器发现没变,回个 304 空响应,浏览器接着用本地缓存。所以 304 是省了响应体的传输,但请求还是发了的。

跟它对比的是强缓存:响应里有 Cache-Control: max-age=3600,在有效期内浏览器压根不发请求,Network 里显示 (from disk cache)(from memory cache),连 304 都不会有。调试时分清这两种很重要——看到 304 说明走了协商缓存(发了请求),看到 from cache 说明走了强缓存(没发请求)。

301 和 302 也别混。301 是永久重定向,浏览器和搜索引擎会记住、缓存这个跳转,下次直接去新地址;302 是临时的。我见过有人把临时跳转配成 301,结果用户浏览器缓存死了旧跳转,后面改回来都不生效,清缓存才好。还有 307/308,是为了修正 301/302 在重定向时可能把 POST 改成 GET 的历史问题,要求严格保持原方法,做接口重定向时要留意这点。

1301 永久,会被缓存,慎用
2302 临时
3307 临时,保持请求方法
4308 永久,保持请求方法

307/308「保持方法」这事可以用 curl 亲眼看。-X POST 发个 POST,加 -L 让它跟着跳转,再用 -v 看每一跳:如果服务端回的是 307/308,curl 跟跳时仍然用 POST 重发;要是回的 301/302,很多客户端(包括浏览器)会把它降级成 GET 重发,请求体也丢了。

1curl -v -L -X POST https://httpbin.org/redirect-to?url=/post\&status_code=307 2>&1 | grep -E '> (POST|GET)'
2# 两跳都是 > POST —— 方法被保持住了
3# 把 status_code 换成 302,第二跳通常会变成 > GET

接口重定向踩过这个坑的都知道:把一个 POST 提交接口配成 302,跳过去就变成 GET 了,参数全没,后端一脸懵。要保方法就得用 307/308。

4xx:这一段的锅,多半在发请求的人

回到那个下午。422 属于 4xx 家族,而 4xx 的统一含义是:请求本身有问题,先查自己。这也是后端那句「你要是第一时间看了状态码」的分量所在——4xx 一出,排查的方向就该掉头了。

1400 Bad Request

请求参数有问题。

1401 Unauthorized

未登录或 token 失效。

1403 Forbidden

已登录但没有权限。

1404 Not Found

资源不存在。

前端对 401 和 403 要区分。401 通常跳登录,403 应该提示无权限或显示权限页。

401 和 403 的区别我用一句话记:401 是「我不知道你是谁」(没登录或登录过期,去登录),403 是「我知道你是谁,但你没资格」(登录了但权限不够,跳了登录页也没用,重新登录还是这个号还是没权限)。把 403 也跳登录是个很常见的体验 bug——用户明明登着,点个功能突然被踹到登录页,登完回来还是不能用,体验很差。正确的做法是 403 停在当前页提示「无权限」或者跳一个专门的无权限页。我们的中后台按角色控权限,运营账号点到超管功能就是 403,这条我们踩过真实客诉。

401 的处理还有个要小心的地方:token 过期时一堆请求可能同时返回 401,如果每个都触发一次「跳登录」,页面会闪、会重复跳。我的做法是在拦截器里加个标志位,401 只处理一次:

1let isRedirecting = false
2
3service.interceptors.response.use(null, error => {
4  const status = error.response && error.response.status
5  if (status === 401 && !isRedirecting) {
6    isRedirecting = true
7    // 清掉本地登录态,跳登录
8    store.commit('clearAuth')
9    router.replace('/login')
10  }
11  return Promise.reject(error)
12})

然后是本文的主角 422 Unprocessable Entity。它和 400 的分工是:400 偏「请求本身坏了」(格式非法、JSON 解析不了),422 是「格式没问题,但语义校验不过」——邮箱格式对但已被注册、时间字段缺失、金额超出范围,都是它。后端那边的框架校验失败默认返回 422,body 里带字段级错误,这套语义其实非常适合前端表单:拦截器认出 422,把 body 里的字段错误直接映射到表单项下面标红,比弹一个笼统的「保存失败」体面得多。那次卡了一下午的教训之后我们真就这么改了——**422 从「我看不懂的报错」变成了表单联调最省事的一条约定。**也有团队一律用 400 加业务码表达校验失败,行不行也行,关键还是前后端说好。

4xx 里再记两个电商后台常撞的。一个是 413 Payload Too Large:上个月商品批量传图的需求,运营传原图动辄七八 MB,Nginx 默认 client_max_body_size 是 1m,超了直接 413,请求根本到不了后端应用——所以后端在日志里死活找不到这条请求,又差点扯一轮皮,最后是运维把 Nginx 配置调了。另一个是 429 Too Many Requests,限流。做了防刷或者调第三方接口时会撞到,前端通常配合 Retry-After 头做退避重试。

5xx:这回真可以去找后端了

1500 Internal Server Error

服务端异常。

1502 Bad Gateway
2503 Service Unavailable
3504 Gateway Timeout

这些常见于网关、代理、服务不可用或超时。

前端遇到 5xx,通常只能提示用户稍后重试,同时上报错误。不要把 500 当成表单校验失败。

5xx 里这几个值得分清,因为它们指向的环节不一样,定位问题方向也不同:

  • 500 是后端代码自己抛异常了,请求其实到了应用服务,是业务逻辑/代码层面的锅。
  • 502 Bad Gateway 是网关(Nginx)把请求转给后端,但后端没正常应答——后端进程挂了、或者还没起来。
  • 503 Service Unavailable 是服务暂时不可用,常见于重启、过载、维护中。
  • 504 Gateway Timeout 是网关等后端响应超时了,多半是某个接口处理太慢,超过了 Nginx 的 proxy_read_timeout

我现在联调如果看到一片 502,基本能判断是后端服务没起来或者刚挂,直接去问后端「服务活着吗」,而不是怀疑自己请求写错了——测试环境重新部署的那几分钟,全是 502,等它起来就好。504 则八成是某个慢查询或慢接口,可以让后端看看那条 SQL 或者外部依赖。这些判断不用很精确,但能给排查指个方向,比一句「接口报错了」有用得多。那次 422 的教训反过来用也成立:5xx 一出,前端可以理直气壮,但 4xx 在手,先把自己的参数翻三遍再开口。

5xx 一般不该重试所有请求——非幂等的写操作(创建、扣款)盲目重试可能造成重复提交。只有明确幂等的读接口,才适合做自动重试加退避。这点要想清楚,别为了「健壮」反而造出脏数据。

HTTP 状态码和业务 code:两套语言怎么共处

很多接口返回:

1{
2  "code": 0,
3  "data": {},
4  "message": "ok"
5}

HTTP 状态码表达协议层,业务 code 表达业务层。

比如名称重复:

1HTTP/1.1 400 Bad Request
1{
2  "code": "NAME_EXISTS",
3  "message": "名称已存在"
4}

也有团队所有业务错误都返回 HTTP 200,再用 code 判断。这样也能做,但前后端必须统一,否则请求层会很混乱。

这两套风格我都碰过,说点真实体感。「一律 200 + 业务 code」这套(国内很流行)好处是前端请求层简单,axios 永远走 then 不进 catch,统一在一个地方判 code === 0。坏处是丧失了 HTTP 语义带来的红利:CDN、网关、监控、浏览器开发者工具都按状态码工作,全返 200 会让监控里看不出错误率,HTTP 缓存也用不上,APM 工具统计接口成功率全是 100%,出了事根本没告警。

「HTTP 状态码 + 业务 code 并用」这套更符合规范,监控和缓存都能用上,但前端拦截器要同时处理 HTTP 层错误(进 catch)和业务层错误(在 then 里判 code),逻辑稍复杂。我个人更倾向这套,但前提是后端能严格遵守语义,最怕的是「一半接口返 400、一半接口 200 里塞错误码」这种半吊子,那才是真正的灾难——前端永远不知道该信状态码还是信 body。

不管选哪套,团队定下来就别中途换、别两套混用。我年初接手过一个混用的老模块,请求层那个 if 嵌套看得人头疼,重构了好久才理顺。

把拦截器重写了一遍

那个下午的另一个产出,是我把我们那个只会说「请求失败」的拦截器彻底重写了。先规整错误结构:

1function normalizeHttpError(response, body) {
2  return {
3    status: response.status,
4    code: body && body.code ? body.code : response.status,
5    message: body && body.message ? body.message : '请求失败'
6  }
7}

axios 拦截器的基本形态:

1service.interceptors.response.use(
2  response => response.data,
3  error => {
4    if (error.response) {
5      return Promise.reject(
6        normalizeHttpError(error.response, error.response.data)
7      )
8    }
9
10    return Promise.reject({
11      status: 0,
12      code: 'NETWORK_ERROR',
13      message: '网络异常'
14    })
15  }
16)

页面只处理统一结构。注意那个 error.response 不存在的分支——请求压根没得到响应(断网、超时、CORS 被拦),这跟「服务器答了个错误」是两码事,必须分开,不然「网络断了」也会被提示成「服务异常」。

在这个基础上,把不同状态码分流到不同处理:401 跳登录、403 提示无权限、422 把字段错误抛给表单、5xx 提示重试并上报、其余的把规整后的 message 抛出去让页面展示。大概长这样:

1service.interceptors.response.use(
2  response => response.data,
3  error => {
4    if (!error.response) {
5      Message.error('网络异常,请检查连接')
6      return Promise.reject({ code: 'NETWORK_ERROR', message: '网络异常' })
7    }
8
9    const { status, data } = error.response
10    const normalized = normalizeHttpError(error.response, data)
11
12    if (status === 401) {
13      handleUnauthorized()
14    } else if (status === 403) {
15      Message.error('没有操作权限')
16    } else if (status === 422) {
17      // 字段级错误不弹全局提示,抛给表单页自己标红
18      normalized.fields = data && data.errors
19    } else if (status >= 500) {
20      Message.error('服务异常,请稍后重试')
21      reportError(normalized)   // 上报到监控
22    }
23
24    return Promise.reject(normalized)
25  }
26)

关键是把「给用户看的提示」和「给开发看的上报」分开:用户只需要知道「成功了还是要重试」,不需要看到 NullPointerException 这种内部细节(泄露内部信息本身也是安全问题);开发则需要完整的状态码、接口、入参进监控,方便定位。两边的诉求不一样,别用同一份信息糊弄过去。

那三个小时之后

HTTP 状态码能帮助前端判断问题类型:2xx 成功,3xx 重定向或缓存,4xx 多是请求或权限问题,5xx 是服务端或网关问题。号码本身半小时就能过完,真正值钱的是那个反射:接口一报错,先看 Status 列,再决定去找谁。

我现在判断一个前端项目的请求层做得好不好,就看它对状态码的处理够不够细:401 会不会重复跳登录、403 是不是还在傻乎乎跳登录、422 的字段错误能不能落到表单上、5xx 有没有上报、204 会不会去 parse 空 body、网络错误(没有 response)和 HTTP 错误分没分开。这些细节做到位,联调效率和线上排障速度会差出一截。

那次事故之后,我们和后端约了一页纸的接口错误约定贴在联调群公告里:校验失败 422 带字段错误、鉴权 401、越权 403、其余业务错误 400 加 code。上周那位实习生联调优惠券的另一个接口,报错后在群里发的第一句话是「状态码 422,我先查参数」。