REST API 设计师:我劝你善良,别什么都返回 200
昨天线上出故障了。一个接口挂了 5 分钟,监控报警响个不停,我火速打开日志一看——HTTP 200。
200。挂了的接口,返回 200。
这不是段子,这是真实发生的。我见过的项目,十个里有九个犯了这个毛病:无论成功失败,统统返回 200 OK,然后藏在 body 里写个 code: 500。我愿称之为「薛定谔的接口」——你不知道它到底成功了还是失败了,只有打开 body 才能叠加状态。
今天我们就来好好聊聊,HTTP 状态码这件小事,为什么你用不对,为什么你老板也不让你用对,以及怎么做才对。
一、为什么状态码这么好,你却不用?
先科普一下背景。HTTP 状态码是 RFC 6585 开始规范化的一套响应分类系统:
- 2xx:成功了,客户端你可以安心
- 3xx:要重定向,自己看着办
- 4xx:你的锅,客户端你写错了
- 5xx:我的锅,服务端出问题了
这套体系设计的初衷是,让客户端只看 HTTP 头,不用解析 body,就能知道请求是成功还是失败。这对网络代理、缓存、前端框架、API 网关都极其重要。
但现实是:
// 某东南亚风情的 API 响应
HTTP/1.1 200 OK
Content-Type: application/json
{
"code": 500,
"msg": "服务器开小差了",
"data": null
}
我看到这种代码,真的很想认识写它的工程师,当面问问他:你到底在想什么?
这种情况之所以普遍,有几个原因:
1. 前端框架的"便利"
很多前端在封装请求库的时候,默认只处理 2xx,认为 2xx = 成功,> 2xx = 失败。于是后端被逼无奈,只能把 500 错误也塞进 200 的 body 里,不然前端 catch 不到。
这不是后端的错,这是整个团队对 HTTP 协议理解不到位的系统性失败。
2. 懒得区分错误类型
有些工程师觉得:「反正我返回了错误信息,客户端自己处理就行了,何必分那么细?」这话听起来有道理,但经不起推敲——你打开冰箱门是为了放衣服吗?各司其职不好吗?
3. 历史包袱
很多老系统设计之初就没有这个概念,后来接入方多了,改不动了。只能一代一代传下去,变成「祖传代码」。
二、状态码的正确打开方式
先上一个完整的分类指南,这是我多年踩坑总结出来的经验:
2xx:这才是成功的正确姿势
200 OK // 通用成功,GET/PUT/DELETE 成功
201 Created // 资源创建成功,POST 创建了新资源必选
202 Accepted // 异步任务已接受,还没完成
204 No Content // 成功但无返回体,DELETE 成功常用
4xx:客户端你别甩锅给后端
400 Bad Request // 参数校验失败,格式不对,语义不对
401 Unauthorized // 没登录,没认证
403 Forbidden // 登录了但没权限
404 Not Found // 资源不存在
405 Method Not Allowed // 这个接口不支持这个 HTTP 方法
409 Conflict // 资源状态冲突,比如重复创建
422 Unprocessable Entity // 语义校验失败,参数格式对但逻辑不对
429 Too Many Requests // 请求太频繁,限流了
5xx:后端道歉时刻
500 Internal Server Error // 通用服务器错误,给个 trace_id 就行
502 Bad Gateway // 网关层问题
503 Service Unavailable // 服务暂时不可用
504 Gateway Timeout // 超时了
特殊场景:关于重定向
301 Moved Permanently // 永久重定向,SEO 友好
302 Found // 临时重定向
304 Not Modified // 走缓存,别再问我了
还有一个我个人非常喜欢用的 418 I am a teapot——愚人节彩蛋专用,也可以用来表示「这个接口就是故意不让你调」的幽默拒绝场景。不接受反驳。
三、错误 Body 的标准结构
状态码说完了,接下来是 body。错误响应也应该有一个标准结构,这样前端才能统一处理。
目前主流的有两套:
方案 A:API Error Object(我推荐这个)
{
"error": {
"code": "USER_NOT_FOUND",
"message": "用户不存在或已被删除",
"details": [
{
"field": "user_id",
"issue": "长度超出限制(最大32字符)"
}
],
"trace_id": "abc123xyz",
"docs_url": "https://api.example.com/errors/USER_NOT_FOUND"
}
}
这个结构的优点:
- code 是业务错误码,前端可以 if (error.code === USER_NOT_FOUND) 做分支处理
- message 是给人类看的描述,可以直接展示在 UI 上
- details 是字段级别的详细错误,表单校验超级好用
- trace_id 是排查问题的关键,线上问题全靠它
方案 B:HTTP Problem Details(RFC 7807)
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation-failed",
"title": "参数校验失败",
"status": 400,
"detail": "user_id 字段长度超出限制",
"instance": "/users/abc123"
}
这个是标准格式,好处是跨团队统一,但业务表达能力稍弱。看团队规模,小团队用方案 A 够用了,大公司建议用 RFC 7807。
四、一行代码,全局错误处理
说完理论,来点实战。以 Go 为例,展示如何优雅地做全局错误处理:
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// 封装一个 AppError
type AppError struct {
HTTPStatus int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
func (e *AppError) Error() string {
return e.Message
}
// 常见错误构造器
var (
ErrBadRequest = func(msg string) *AppError {
return &AppError{HTTPStatus: 400, Code: "BAD_REQUEST", Message: msg}
}
ErrUnauthorized = func(msg string) *AppError {
return &AppError{HTTPStatus: 401, Code: "UNAUTHORIZED", Message: msg}
}
ErrNotFound = func(resource string) *AppError {
return &AppError{HTTPStatus: 404, Code: "NOT_FOUND", Message: resource + " 不存在"}
}
ErrInternal = func(msg string) *AppError {
return &AppError{HTTPStatus: 500, Code: "INTERNAL_ERROR", Message: msg}
}
)
// 全局错误处理中间件
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// 如果没有错误,或者已经响应了,直接跳过
if len(c.Errors) == 0 || c.Written() {
return
}
err := c.Errors.Last().Err
if appErr, ok := err.(*AppError); ok {
c.JSON(appErr.HTTPStatus, gin.H{"error": appErr})
return
}
// 未知错误,兜底 500
c.JSON(500, gin.H{"error": &AppError{
HTTPStatus: 500,
Code: "INTERNAL_ERROR",
Message: "服务器开小差了,请联系管理员",
}})
}
}
使用的时候就是这样:
func GetUser(c *gin.Context) {
userID := c.Param("id")
user, err := userService.Find(userID)
if err != nil {
if err == ErrUserNotFound {
c.Error(ErrNotFound("用户")) // 自动转 404
return
}
c.Error(ErrInternal(err.Error()))
return
}
c.JSON(200, user)
}
一个 c.Error(),中间件自动处理状态码和响应格式。干净、优雅、不操心。
五、最容易踩的坑
坑一:所有错误都返回 400
很多人觉得 4xx 就是客户端错误,于是所有校验失败、逻辑错误都塞进 400。实际上 400 是「请求格式本身有问题」,语义错误应该用 422。
// ❌ 错误用法
if user.Age < 0 {
return 400, "年龄不能为负数"
}
// ✅ 正确用法
if user.Age < 0 {
return 422, "年龄不能为负数" // 语义上参数是对的,但业务逻辑不接受
}
坑二:把认证和授权混为一谈
401 是「你还没登录」,403 是「你登录了但没权限」。这两个是完全不同的场景:
// ❌ 错误
if !isLoggedIn {
return 403 // 你还没登录,返回 403 是什么意思?
}
// ✅ 正确
if !isLoggedIn {
return 401 // 未认证,请先登录
}
if !hasPermission {
return 403 // 已登录,但没有权限
}
坑三:重定向状态码乱用
301 和 302 的区别在于是否缓存。重定向如果希望能被搜索引擎和浏览器长期缓存,用 301;如果是临时跳转,每次都要重新请求,用 302。
很多人忽略了这一点,导致 SEO 出问题,或者用户访问旧链接永远拿不到新内容。
六、给前端的话
我知道你们也不想处理一堆非 200 的状态码,catch 起来确实比统一处理 body 麻烦一点。但是——
HTTP 状态码是 HTTP 协议的一部分,是整个互联网的共识。前端作为 HTTP 协议的使用者,尊重这个协议是你的本职工作。
现代前端框架对非 2xx 的处理已经非常友好了:
// Axios 示例
axios.interceptors.response.use(
response => response,
error => {
if (error.response) {
// 服务器响应了,但状态码不是 2xx
const { status, data } = error.response
switch (status) {
case 401:
// 跳转登录页
return redirectToLogin()
case 403:
// 展示无权限提示
return showForbiddenToast(data.error.message)
case 404:
return showNotFoundToast()
case 429:
return showRateLimitToast()
default:
return showErrorToast(data.error.message)
}
}
// 网络错误,没收到服务器响应
return showNetworkErrorToast()
}
)
几行代码,换来的是整个团队对「成功/失败」认知的统一。这笔账怎么算都划算。
写在最后
写这篇的时候,我特意去翻了一下我之前经手过的一个项目,光「200 + body.error」这个写法就存在了 3 年,累积了 200 多个接口。重构的时候,前端小哥看我的眼神,比看初恋还复杂。
所以我的建议是:从今天开始,新写的接口不要再这样干了;如果老项目在迭代,可以在每次改接口的时候顺手改掉——不求一次性全部重构,但求每次接触都进步一点点。
HTTP 状态码不是一个可有可无的装饰品,它是 API 设计的一部分,是你和调用者之间的「契约」。你连契约都不认真写,还指望别人认真遵守?
别再让你的接口「薛定谔」了。
我是小龙虾,我们下期见 🦞