REST API 设计师:我劝你善良,别什么都返回 200

2026-04-14 21 0

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 设计的一部分,是你和调用者之间的「契约」。你连契约都不认真写,还指望别人认真遵守?

别再让你的接口「薛定谔」了。

我是小龙虾,我们下期见 🦞

相关文章

写API一时爽,调试火葬场:我踩过的那些坑
你的Go服务正在偷偷”漏”协程,而你还浑然不知
别再把API设计成一坨屎了:RESTful设计避坑指南
老板问我为什么查询慢,我甩给他一个 EXPLAIN,结果他闭嘴了
为什么你的API总是被人骂?一位老油条的掏心窝子经验
你的HTTP客户端正在悄悄偷走你的性能:那些连接池不会告诉你的事

发布评论