> 本文讲讲 HTTP 状态码和业务错误码那些不得不说的故事,看完你会回来点赞的。
做后端开发这么多年,最让我崩溃的时刻不是数据库挂了,不是 Redis 挂了,而是——
**前端同事怒气冲冲地跑过来:"你们接口返回 200 了,为什么显示错误?!"**
我打开 Postman 一看,好家伙,响应体里明晃晃地写着 `"code": 400, "message": "参数错误"`,但 HTTP 状态码是 200 OK。
这种情况,我称之为「温柔的谎言」——HTTP 状态码说一切安好,业务代码却在悄悄哭泣。
今天我们就来聊聊这个几乎所有团队都会踩的坑:**HTTP 状态码与业务错误码的混用问题**。
## 01. 为什么我们总是写错?
让我猜猜,你的 API 是不是也长这样:
```json
HTTP/1.1 200 OK
Content-Type: application/json
{
"code": 400,
"message": "用户名已存在",
"data": null
}
```
为什么会这样?因为——
**第一,懒。** 很多人觉得返回 200 最省事,前端好处理,SSR 友好,服务器也不报错。管它什么状态码,能跑就行。
**第二,害怕。** 返回 400、500 状态码,前端可能会直接进入错误分支,展示那个丑丑的「网络错误」页面。产品经理会说:"为什么又报错?体验不好!"
**第三,没规范。** 团队没有明确的 API 错误处理规范,大家各写各的,最后变成一坨浆糊。
但正是这种「温柔」的写法,会让前端崩溃,会让监控失效,会让调试变成噩梦。
## 02. 这件事有多严重?
让我告诉你几个真实案例:
**案例一:监控形同虚设**
某公司的监控系统配置了「5xx 错误告警」。结果某天业务逻辑全面崩盘,错误率飙到 40%,监控系统一声不吭。
为什么?因为所有接口都返回 200,错误信息全藏在 body 里的 `code` 字段。监控只能看到 HTTP 状态码,当然看不到业务错误。
**案例二:前端判断失效**
前端小李写了一个通用的错误处理函数:
```javascript
if (response.status >= 400) {
showErrorToast('请求失败');
}
```
然后他发现,无论返回什么错误,toast 永远不显示。因为后端永远返回 200。
于是他改成了:
```javascript
const data = await response.json();
if (data.code !== 0 && data.code !== 200) {
showErrorToast(data.message);
}
```
然后他离职了。后面的同事看着这坨代码,完全不知道 `0` 和 `200` 是什么意思,为什么有的接口用 `0` 表示成功,有的用 `200`。
**案例三:网关彻底抓瞎**
现在很多公司用 Kong、APISIX 等 API 网关做流量治理、限流、熔断。结果网关配置了「错误率超过 10% 自动熔断」,却永远触发不了。
因为所有流量都返回 200,网关看到的错误率永远是 0%。
你配置的熔断?摆设罢了。
## 03. 正确的姿势是什么?
### 方案一:严格遵守 HTTP 状态码
让 HTTP 状态码回归它的本职工作:
- **200-299**:成功
- **400**:客户端错误(参数错误、认证失败、权限不足)
- **401**:未认证
- **403**:已认证但没权限
- **404**:资源不存在
- **409**:资源冲突(典型的如用户名重复)
- **422**:请求格式正确但语义错误
- **429**:请求过多
- **500**:服务器炸了
- **503**:服务不可用
```json
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "username_taken",
"message": "用户名已被占用",
"details": {
"field": "username",
"value": "admin"
}
}
```
这样做的优点:
- HTTP 状态码终于有意义了
- 网关、监控可以正常工作
- 前端可以统一处理
缺点:
- 前端需要改代码
- 部分产品经理会尖叫"为什么又报错"
### 方案二:混合模式(推荐)
如果你的团队实在接受不了全面拥抱 HTTP 状态码,可以采用混合模式:
**对于真正的技术错误(服务器挂了、数据库超时、接口不存在):** 严格使用 HTTP 状态码。
**对于业务逻辑错误(参数不对、业务规则不符):** 在 body 里用业务错误码,但 HTTP 状态码统一用 200。
等等,上面说了半天混合模式不就是在原来的坑里打转吗?
别急,关键在于**约定**:
```json
{
"success": true,
"code": 0,
"message": "",
"data": { ... }
}
```
或者:
```json
{
"success": false,
"code": 1001,
"message": "用户名已存在",
"data": null
}
```
重点是:**统一约定,全局遵守**,不要每个接口一套自己的玩法。
### 方案三:GraphQL 之下的错误处理
如果你用的是 GraphQL,恭喜你,这个问题更复杂了。
GraphQL 的响应永远都是 200(除非网络层面的问题),所有的错误都藏在 `errors` 字段里:
```json
{
"data": null,
"errors": [
{
"message": "用户名已存在",
"locations": [{ "line": 3, "column": 5 }],
"path": ["createUser"],
"extensions": {
"code": "USERNAME_TAKEN",
"field": "username"
}
}
]
}
```
这又是另一个话题了,今天先不展开。
## 04. 我的建议
经过这么多年的踩坑,我的建议是:
**1. 制定明确的 API 规范**
别让每个人自己发挥。规定好:
- 什么情况用什么 HTTP 状态码
- 业务错误码怎么定义
- 响应体结构长什么样
**2. 让监控和网关参与进来**
如果你的监控系统只能监控 HTTP 状态码,那就要么改造监控系统,要么让 HTTP 状态码真正发挥作用。别让监控变成装饰品。
**3. 前端后端坐下来聊一聊**
很多矛盾来自于沟通不畅。让前端知道为什么会有错误,让后端知道前端怎么处理错误。一起制定规范,比单方面强制执行效果好一百倍。
**4. 错误码要文档化**
不要让错误码散落在代码各处。搞一个集中的错误码文档,或者用 OpenAPI 规范定义好所有可能的错误。
## 05. 写在最后
回到开头的问题:为什么你的 API 返回 200 却显示错误?
因为我们总想着"别让用户看到错误",结果却是——
- 监控看不见错误
- 网关看不见错误
- 运维看不见错误
- 只有用户能看见错误,还是在页面上
这不是在帮用户,这是在害所有人。
**好的错误处理,不是隐藏错误,而是让错误在正确的地方被正确地处理。**
希望这篇文章能帮你的团队少踩这个坑。如果你的团队也在这件事上挣扎,欢迎在评论区聊聊你们是怎么解决的。