做过 API 开发的人,大概都有过这样的经历:凌晨两点,线上报警响了,你打开日志,发现错误信息写的是 Something went wrong。你望着屏幕,心里有一万只草泥马奔腾而过。
错误处理,是 API 设计里最容易被忽视、但却最能体现工程师素养的部分。一个好的错误设计,能让调用方少掉一半头发;一个烂的错误设计,能让整个团队陷入无休止的联调地狱。
今天,咱们就来好好聊聊:怎么设计 RESTful API 的错误处理,让错误信息真正有用,而不是一堆给机器看又看不懂、给人看又没用的垃圾字符串。
一、HTTP 状态码:你真的用对了吗?
很多人对 HTTP 状态码的态度是:200 是成功,500 是服务器问题,中间随便选。这是对状态码最大的侮辱。
先来一个灵魂拷问:你知道202、204、206 的区别吗?你用过 409 Conflict 吗?429 Too Many Requests 是不是你只在别人的代码里见过?
HTTP 状态码是 API 向调用方传达结果的第一道语言。学不好这门语言,你的 API 就是一本充满"呃"、"嗯"、"可能吧"这样模糊词汇的对话手册。
先上一张图,把状态码的层次理清楚:
1xx - 等等,我还在处理(很少用到,WebSocket 场景除外)
2xx - 成功,稳稳的
200 OK - 标准成功
201 Created - 资源创建成功
204 No Content - 成功但没返回内容(适合 DELETE 操作)
3xx - 重定向,但别在我的 API 里乱用
4xx - 客户端的锅,我不背不行但我要说清楚
400 Bad Request - 参数格式错误
401 Unauthorized - 没登录(认证失败)
403 Forbidden - 登录了但没权限(授权失败)
404 Not Found - 资源不存在
409 Conflict - 状态冲突(比如重复创建)
422 Unprocessable Entity - 格式对但语义错(validation失败)
429 Too Many Requests - 请求太过频繁,悠着点
5xx - 我的锅,但我要说清楚是哪里的锅
500 Internal Server Error - 通用服务器错误
502 Bad Gateway - 上游服务出问题了
503 Service Unavailable - 服务暂时不可用
504 Gateway Timeout - 上游超时了
几个实战经验:
1. 不要用 200 返回错误。有些接口会这样写:
// 反模式!不要这样做!
return res.status(200).json({
success: false,
error: 'Invalid token',
code: 'AUTH_001'
});
这是脱了裤子放屁——多此一举。HTTP 状态码本身就是用来表示结果的,调用方完全可以根据状态码做分支判断。你在200 里塞一个 success: false,就是在侮辱 HTTP 协议。
2. 区分 401 和 403。这两个是最容易被混用的。401 是"你还没证明你是谁",403 是"你证明了你是谁,但你没权限"。混用了,调用方就不知道到底是用户没登录,还是登录了但权限不够。
3. 善用 422。422 Unprocessable Entity 是一个被严重低估的状态码。它表示"你的请求格式我认识了,内容我也解析成功了,但是这个内容不符合业务规则"。这正是 validation 失败应该用的状态码。
很多框架默认把 validation 失败返回 400,但400 的语义是"这个请求我根本看不懂"——validation 失败明显不是这个意思。
二、错误响应体:一致性才是王道
状态码是第一层语言,错误响应体是第二层。一个丑的响应体能瞬间让一个状态码正确的 API 变得难以使用。
错误响应体设计的核心原则是:一致性。不管哪个接口出错,调用方都应该能以同样的方式解析错误。
我见过最离谱的错误响应体是这样的:
// 接口 A 的错误
{ "message": "User not found" }
// 接口 B 的错误
{ "error": "NOT_FOUND", "detail": "用户不存在" }
// 接口 C 的错误
{ "msg": "用户不存在,请检查输入", "code": 404 }
// 接口 D 的错误
"User not found"
这种 API 让调用方写解析逻辑的时候,就像在玩俄罗斯方块——你永远不知道下一个错误会是什么形状。
我的建议是,统一错误响应格式,推荐这样做:
{
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"details": [
{
"field": "userId",
"message": "userId 不能为空"
}
],
"requestId": "req_abc123xyz"
}
各字段的含义:
- code:业务错误码,字符串格式,供程序做分支判断。格式建议是
RESOURCE_ACTION_REASON,比如USER_NOT_FOUND、ORDER_ALREADY_PAID。 - message:给开发者看的错误描述,要清晰、有信息量。不要写"操作失败",要写"订单已支付,无法重复支付"。
- details:可选字段,当错误涉及多个字段时(比如 validation 失败),在这里列出每个字段的具体问题。
- requestId:非常重要!这是连接前端错误日志和后端服务日志的桥梁。生产环境出问题,调用方报 bug 的时候只需要提供一个 requestId,你就能在日志系统里找到完整的调用链。
另外,关于 message 字段,我有一个暴论:错误信息不是写给用户的,是写给开发者的。很多人在 error message 里写"请稍后再试"或者"请联系管理员",这是懒得思考的借口。一个好的 error message 应该告诉调用方:出了什么错、可能是什么原因、怎么修复。
举个例子:
// 差的错误信息
"Invalid parameter"
// 还行的错误信息
"Parameter 'page' must be a positive integer"
// 好的错误信息
"Parameter 'page' must be a positive integer, received: -1"
最后一种,不解释,调用方直接就知道自己错哪儿了。
三、业务错误码:建立你的错误字典
有些项目里,错误码是自由发挥的:ERR_001、ERR_002、A0001。这些码没有规律、没有文档、不成体系,时间长了连写的人自己都不知道哪个是哪个了。
一个好的业务错误码体系,应该像一本字典:有序、可预测、可查询。
推荐按模块划分错误码:
// 格式:{模块}_{操作}_{原因}
// 示例
USER_NOT_FOUND // 用户模块 - 查询 - 未找到
USER_CREATE_DUPLICATE // 用户模块 - 创建 - 重复
ORDER_PAYMENT_EXPIRED // 订单模块 - 支付 - 已过期
ORDER_CANCEL_NOT_ALLOWED // 订单模块 - 取消 - 不允许
AUTH_TOKEN_EXPIRED // 认证模块 - Token - 已过期
AUTH_TOKEN_INVALID // 认证模块 - Token - 无效
这样设计的错误码,调用方即使不看文档,通过字面意思也能猜出错误的大概范围。
在代码里,我推荐用枚举或者常量来管理错误码,而不是随手写字符串:
// errors/user.go
package errors
var UserNotFound = NewError("USER_NOT_FOUND", "用户不存在")
var UserCreateDuplicate = NewError("USER_CREATE_DUPLICATE", "用户已存在")
// NewError 返回一个标准的业务错误
func NewError(code, message string) *BusinessError {
return &BusinessError{
Code: code,
Message: message,
}
}
// BusinessError 业务错误
type BusinessError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// Error 实现 error 接口
func (e *BusinessError) Error() string {
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
这样做的好处是:写代码时有 IDE 自动补全,写错了编译器会报错,而且错误码不会拼写不一致。
四、Validation:错误处理的第一战场
Validation(输入校验)是错误处理里最复杂、也最考验工程能力的部分。因为 validation 失败通常涉及多个字段,每个字段可能有多种错误。
一个典型的 validation 失败场景:
// 请求体
{
"username": "a",
"email": "not-an-email",
"password": "123",
"age": -1
}
四个字段全错了,正确处理应该是:
{
"code": "VALIDATION_ERROR",
"message": "请求参数校验失败",
"details": [
{ "field": "username", "message": "用户名长度需要在 3-20 个字符之间" },
{ "field": "email", "message": "邮箱格式不正确" },
{ "field": "password", "message": "密码至少需要 8 个字符" },
{ "field": "age", "message": "年龄必须为正数" }
],
"requestId": "req_xyz789"
}
返回 422 状态码,一次性告诉调用方所有的问题,而不是一次返回一个字段的错误让他们反复调试。这种体验差异,做过联调的人都懂。
在后端框架里,validation 库通常都支持批量收集错误。以 Go 为例:
// 使用 go-playground/validator
type RegisterRequest struct {
Username string `validate:"min=3,max=20"`
Email string `validate:"required,email"`
Password string `validate:"required,min=8"`
Age int `validate:"required,min=0"`
}
func validateRequest(r *RegisterRequest) []FieldError {
var fieldErrors []FieldError
// validation 逻辑...
return fieldErrors
}
前端拿到这个响应后,可以在表单里精准地把错误显示在对应字段下面,而不是弹一个通用的 toast 告诉用户"填写信息有误"——这种体验堪称反向优化。
五、生产环境:日志和监控才是大招
说了这么多设计层面的东西,但实际生产环境中,错误处理的终极战场是日志和监控。
你的 API 错误设计得再好,如果没人知道它出错了,那和没人知道你分手了是一样惨的——痛苦只有自己知道。
1. 记录 requestId 到日志
每一个请求,从进入系统开始就分配一个唯一的 requestId,这个 ID 要贯穿整个调用链:网关 → 服务 A → 服务 B → 数据库。出了错,日志里只要搜这个 ID,就能看到完整的调用链。
2. 区分不同级别的错误日志
不是所有错误都需要报警。400 错误(客户端问题)在生产环境中是正常业务逻辑,不需要报警。但 500 错误必须报警。429 错误需要监控频率,如果某个 IP 突然开始大量触发 429,说明可能在遭受攻击。
3. 建立错误大盘
用 Grafana 或者类似工具,监控你的 Top 错误码分布。如果某个错误码突然多了起来,往往是某个依赖服务出了问题的前兆。
六、总结:好的错误处理是一种体贴
写了这么多,最后总结一下:好的错误处理,本质上是一种体贴——对调用方的体贴,对接你接口的开发者的体贴,对自己团队未来少踩坑的体贴。
你设计 API 的时候多花十分钟想清楚错误响应,生产环境就能少花十个小时联调排查。这种投资回报率,比你多写一个功能高多了。
下次当你忍不住写 Something went wrong 的时候,想想凌晨两点被报警叫醒的同事,想想联调时对着模糊错误信息发愣的后端开发,想想自己当初为什么选择了这个行业。
然后,把错误信息写清楚。
附:常见 HTTP 状态码速查表,建议收藏(如果你的浏览器还保留着收藏夹的话)。