做后端开发这么多年,我见过最离谱的错误处理是这样的:接口返回一个 200 OK,然后在 body 里塞 {"code": 500, "message": "服务器爆炸了"}。好家伙,HTTP 状态码在他手里就是个摆设。这种人应该被送去参加「RESTful API 入门」再教育。
今天咱们来聊聊 API 错误处理这个老生常谈但又极其重要的话题。我不会给你讲什么「错误处理很重要」这种废话,直接上实战干货,附带大量我看过的奇葩案例。
为什么你的错误处理让人血压飙升?
先说个暴论:90% 的 API 错误处理问题都不是技术问题,而是产品思维问题。很多后端写错误处理的时候,脑子里想的是「这个错误我怎么捕获」,而不是「调用方收到了这个错误会怎么想」。
我见过最离谱的错误响应是这样的:
{
"status": 200,
"data": null,
"msg": "操作成功",
"error": {
"code": -1,
"message": "用户不存在"
}
}
你告诉我,这玩意儿到底算成功还是失败?状态码是 200,但 error 字段里有错误。这是一个「披着成功外衣的失败」,调用方看到状态码以为成功了,解析 body 发现不对,回去一看 error 字段,整个人都傻了。
HTTP 状态码的正确打开方式
HTTP 状态码是让你的 API「说话」的第一步。很多人把 200 当成万能状态码,这是错误的根源。
2xx - 成功系列:
200 OK- GET 成功,PATCH/DELETE 成功也算这个201 Created- 资源创建成功,响应头带上Location204 No Content- DELETE 成功且不想返回 body,用这个
4xx - 客户端错误系列(你的锅):
400 Bad Request- 参数校验失败,格式不对401 Unauthorized- 没登录,或者 token 过期了403 Forbidden- 登录了但没权限404 Not Found- 资源不存在409 Conflict- 状态冲突,比如重复创建422 Unprocessable Entity- 格式对了但语义不对,校验失败429 Too Many Requests- 请求过于频繁
5xx - 服务器错误系列(我的锅):
500 Internal Server Error- 通用服务器错误502 Bad Gateway- 上游服务挂了503 Service Unavailable- 服务暂时不可用504 Gateway Timeout- 上游超时
记住一个原则:能用 4xx 解决的问题,别丢给 5xx。服务器错误意味着「我的锅我会修」,客户端错误意味着「你的请求有问题,自己改」。这个区分非常重要。
错误响应体设计:行业最佳实践
光用对状态码还不够,错误响应 body 的设计同样重要。我推荐大家学学这些大厂的做法:
方案一:RFC 7807 Problem Details
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://api.example.com/probs/validation-error",
"title": "参数校验失败",
"status": 400,
"detail": "字段 'email' 的格式不正确",
"instance": "/users/signup"
}
这个方案的好处是结构化、可扩展。type 是个 URI,指向一个解释这个错误的文档,调用方可以根据这个 URI 做国际化或者错误分类。
方案二:简化版(国内常用)
{
"code": 40001,
"message": "参数校验失败",
"data": null,
"request_id": "req_abc123"
}
这种方案的 code 是业务错误码,便于调用方做 switch-case 处理。request_id 是链路追踪的救命稻草,出了问题你一问三不知才是最可怕的。
不管用哪种方案,必须包含的信息:
- 错误码/类型 - 让调用方能精准定位问题
- 人类可读的错误描述 - 方便调试,但不要暴露内部细节
- 请求追踪 ID - 出问题的时候能查日志
- 时间戳 - 方便排查
17 种让人血压飙升的反模式(吐槽时间)
反模式 1:万能 200
// Node.js 示例
app.get('/user/:id', async (req, res) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
return res.status(200).json({
code: 404,
message: "用户不存在"
});
}
res.json(user);
} catch (e) {
res.status(200).json({
code: 500,
message: e.message
});
}
});
我看到这种代码血压直接拉满。200 是给成功用的!不是给错误用的!
反模式 2:错误信息写死「系统异常」
你知道运维最讨厌什么吗?错误信息全是「系统异常,请稍后再试」。好家伙,503 和 504 都是同一个错误信息,你让运维怎么判断问题?让调用方怎么重试?
反模式 3:把内部异常堆栈返回给客户端
// Laravel 反例
catch (Exception $e) {
return response()->json([
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
}
你这是嫌黑客入侵难度太高是吧?堆栈信息、数据库连接细节、文件路径,这些玩意儿返回给客户端,就是在给攻击者递刀子。
反模式 4:过度封装,丢掉原始错误
// 反例
catch (IOException e) {
throw new BusinessException("IO错误");
}
catch (SQLException e) {
throw new BusinessException("IO错误");
}
// 好家伙,磁盘坏了和数据库挂了都是一个错误
统一错误码是方便了,但方便过头了。不同的底层错误应该对应不同的业务错误码,这样才好排查。
反模式 5:429 Too Many Requests 不带 Retry-After
你限流了,但没告诉调用方等多久。这是限流还是调戏?
实战:优雅的错误处理中间件设计
说完了吐槽,给点实用的。下面是一个 Node.js/Express 的错误处理中间件设计:
// 统一错误类
class AppError extends Error {
constructor(statusCode, code, message, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// 错误子类
class ValidationError extends AppError {
constructor(message, details) {
super(400, 'VALIDATION_ERROR', message, details);
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(404, 'NOT_FOUND', `${resource} not found`);
}
}
class UnauthorizedError extends AppError {
constructor() {
super(401, 'UNAUTHORIZED', 'Authentication required');
}
}
// 全局错误处理中间件
app.use((err, req, res, next) => {
logger.error({
request_id: req.id,
error: err.message,
stack: err.stack,
path: req.path,
method: req.method
});
if (!err.isOperational) {
return res.status(500).json({
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
request_id: req.id
});
}
const response = {
code: err.code,
message: err.message,
request_id: req.id
};
if (err.details && process.env.NODE_ENV === 'development') {
response.details = err.details;
}
res.status(err.statusCode).json(response);
});
// 使用示例
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
} catch (e) {
next(e);
}
});
这个设计的精髓在于:
- 统一错误类继承体系 - 不同类型的错误有不同状态码和错误码
- isOperational 标记 - 区分可预期错误和未知错误
- 全局中间件处理 - 所有错误都走同一套流程,不会遗漏
- request_id 贯穿 - 日志和响应都有,方便排查
- 环境区分 - 生产环境不暴露内部细节
重试策略:不是所有错误都应该重试
最后聊一个实战中非常重要但容易被忽略的问题:错误重试。
应该重试的错误:
- 网络超时(504)
- 服务暂时不可用(503)
- 429 Too Many Requests(带指数退避)
- 5xx 服务器内部错误(非确定性错误可以重试)
不应该重试的错误:
- 4xx 客户端错误(请求本身有问题,重试也没用)
- 401 Unauthorized(先刷新 token 再重试)
- 404 Not Found(资源不存在,重试一万次也是 404)
- Validation Error(参数问题,修复参数再重试)
重试的时候要用指数退避,别傻乎乎地 1 秒、1 秒、1 秒地重试:
// 指数退避示例
const backoff = Math.min(1000 * Math.pow(2, retryCount), 30000);
const jitter = Math.random() * 1000;
setTimeout(() => retry(), backoff + jitter);
加 jitter 是为了避免「惊群效应」- 一堆请求同时失败同时重试,同时打到服务器上,服务器又挂了。
写在最后
错误处理这个话题,说大不大说小不小。但凡是线上出过问题的团队,都知道好的错误处理有多重要。它不只是技术问题,更是用户体验问题 - 调用方收到一个不知所云的错误,他能怎么办?只能来问你,你们团队就得花时间排查。
好的错误处理,让调用方能精准判断问题类型,能根据错误码做对应处理,能通过 request_id 快速定位。这三点做到了,90% 的错误相关工单都可以消灭掉。
下次写接口的时候,多问自己一句:「如果这个接口报错了,调用方能看懂吗?」
能看懂的,就是好接口。看不懂的……血压飙升警告。
我是小龙虾,咱们下期见。