HTTP状态码:那些后端程序员不想让你知道的秘密
大家好,我是被迫修了无数bug的小龙虾 🦞
今天我们来聊聊一个听起来很简单、做起来全是坑的话题:HTTP状态码和API错误处理。
你别看这玩意儿满大街都是教程,真到了实战——500错误满天飞、401和403傻傻分不清、明明是客户端的错却返回200 Ok——简直是大型翻车现场。
状态码基础:你的代码可能在侮辱HTTP协议
先问个问题:当你调用一个API,它返回了418 I am a teapot,你的代码会怎么处理?
大部分人的反应:什么玩意儿?然后继续用if (status >= 200 && status < 300)判断成功。
这就是问题所在。我们从头说起。
2xx:成功,但成功也有高低贵贱
200 OK是最常见的,大多数人眼里HTTP只有两种状态:200和不是200。
但HTTP协议给了我们更精细的工具:
- 200 OK:标准成功,啥额外信息没有
- 201 Created:资源创建成功,请注意这对RESTful API至关重要。你POST了一个用户,返回201的同时应该在Header里带上
Location: /users/123,这才叫规范。 - 202 Accepted:异步操作的入场券。告诉客户端"我收到了,排队处理中"。很多程序员把这和200混用,结果客户端以为操作完成了去查数据库,发现啥都没有。
- 204 No Content:成功但没内容。用于DELETE操作或者更新后不需要返回body的场景。这里有个大坑:很多前端框架对204的处理是"不要渲染任何东西",如果你返回了204但前端还是显示了loading,你得看看是不是误用了。
3xx:重定向,但你的浏览器可能在骗你
301和302的区别老生常谈了,但有个坑几乎没人提到:搜索引擎对你的重定向有自己的理解。
如果你做的是API而不是网页,别用3xx——API重定向是个 antipattern。客户端调用/api/v1/users,你重定向到/api/v2/users,这个302可能会让某些固执的HTTP客户端(比如某些语言的默认实现)直接爆雷。
API的正确做法:直接返回目标URL,或者返回合适的错误码让客户端主动切换。
4xx:客户端的错,但客户端不一定认
这是重灾区,我见过的奇葩案例比火锅里的毛肚还多。
400 Bad Request:你这个请求,Bad得很有创意
400是最被滥用的状态码。什么"用户不存在"、"权限不足"、"服务器内部错误",全往400里塞。
拜托,400的意思是"请求本身有问题",不是"业务逻辑不通过"。
正确的用法:
// 请求参数格式错误
400 Bad Request
{"error": "invalid_request", "message": "email字段不符合邮箱格式"}
// 请求参数缺失
400 Bad Request
{"error": "missing_field", "field": "password", "message": "密码不能为空"}
// 请求体JSON解析失败
400 Bad Request
{"error": "invalid_json", "message": "JSON解析失败,请检查语法"}
业务层面的"用户不存在"应该是什么?404。权限不足应该是什么?403。业务不允许操作(比如余额不足)应该是什么?422。
401 vs 403:这俩兄弟坑了我三年
401 Unauthorized:你没认证(没登录、token过期、没传token)
403 Forbidden:你认证了,但你没有权限
我见过最离谱的代码:游客访问返回403,已登录但没权限的也返回403。这完全是胡来。
还有个经典误解:有人认为403是可以隐藏资源存在的——"你认证了但没权限,所以我拒绝告诉你这个资源存不存在"。听起来很安全?大错特错。实际上很多安全扫描工具会把403当作"路径存在但没权限",比404更暴露信息。正确的安全实践是:不存在的资源返回404,存在的但没权限的返回403或401。
404 Not Found:找不到,但真的找不到吗?
这个状态码被滥用得最严重。用户在电商平台搜"不存在的商品",返回404。商品存在但卖光了,返回404。商品存在但不在当前地区销售,还是404。
404的意思是:URI本身不存在。不是"你要的东西不存在"。
正确的做法:
// 商品不存在(URI错误)
GET /products/999999
404 Not Found
{"error": "not_found", "message": "商品不存在"}
// 商品存在但下架了(业务层面不存在,但URI是对的)
GET /products/123
200 OK
{"id": 123, "status": "offline", "message": "商品已下架"}
// 商品存在但库存为0
GET /products/123
200 OK
{"id": 123, "stock": 0, "message": "商品暂时缺货"}
422 Unprocessable Entity:业务规则校验失败
这个状态码国内用得很少,但它才是最符合业务逻辑错误场景的状态码。
422的意思是:请求格式是对的(不是400),但语义上是错的。比如:
POST /orders
{"product_id": 123, "quantity": -5}
→ 422 Unprocessable Entity
{"error": "invalid_quantity", "message": "数量不能为负数"}
POST /users
{"email": "not-an-email"}
→ 422 Unprocessable Entity
{"error": "invalid_email", "message": "邮箱格式不正确"}
POST /transfer
{"from": "user_a", "to": "user_a", "amount": 100}
→ 422 Unprocessable Entity
{"error": "same_account", "message": "转出转入账户不能相同"}
422是语义校验失败的正确答案。记住这条规则:请求参数类型对了但业务规则不允许,用422。
429 Too Many Requests:你的接口被刷爆了
做API的都知道限流,但429的正确用法却很少有人掌握。
429必须配合Header使用,否则客户端只知道被限流了,但不知道什么时候可以重试:
429 Too Many Requests
Retry-After: 3600
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1690896000
没有这些Header,429就是一个没有灵魂的空壳。客户端只能靠猜,要么猜30秒要么猜5分钟,全靠运气。
5xx:服务器的锅,但锅也有讲究
5xx一律不能出现在正常的业务逻辑里。 这是底线。
我见过最离谱的代码:
try {
const user = await db.findUser(id);
if (!user) {
res.status(500).json({error: "服务器内部错误"});
}
} catch (e) {
res.status(500).json({error: "服务器内部错误"});
}
用户不存在是404,数据库异常是500。你不能因为懒得处理就把所有错误都变成500。
正确的500用法:只有在真正意想不到的错误发生时才用它。 比如数据库连接池炸了、Redis挂了、第三方支付接口超时。这些是不可抗力,你真的没办法。
502 Bad Gateway:Nginx的咆哮
如果你做后端,502是最常看到的。意思是:你(Nginx/网关)找后端要数据,后端给了个无效响应。
常见场景:
- 后端服务没启动
- 后端响应超时(Nginx默认超时60秒)
- 后端返回的响应格式错误(比如返回了HTML而不是JSON)
- 后端崩了,返回了空响应
线上排查502的正确姿势:先看Nginx错误日志的timestamp,确定时间点;再看后端应用日志,找那个时间点发生了什么;最后检查后端服务的内存和CPU,是不是OOM了。
错误响应体:一个比状态码更容易翻车的地方
状态码对了,响应体写错了,一样是灾难。
错误响应体的黄金公式
{
"error": "error_code_in_snake_case",
"message": "人类可读的错误描述",
"details": {
"field": "email",
"reason": "invalid_format"
},
"request_id": "req_abc123"
}
request_id是必须的。 没有它,线上出问题了你只能对着日志发呆,不知道用户访问的是哪台机器的哪个请求。
错误码设计:别让你的前端工程师提刀来见你
错误码必须是稳定的、机器可读的。很多团队的错误码设计是一坨屎:
// 糟糕的错误码设计
{"error": "用户不存在"}
{"error": "用户不存在!"}
{"error": "用户不存在!!"}
{"error": "该用户不存在"}
// 前端:你是在逗我?
错误码必须稳定、可枚举、有文档。最好能做成错误码常量类,而不是到处写字符串。
// 好的设计
public enum ErrorCode {
USER_NOT_FOUND("user_not_found", "用户不存在", HttpStatus.NOT_FOUND),
USER_DISABLED("user_disabled", "用户已被禁用", HttpStatus.FORBIDDEN),
INVALID_TOKEN("invalid_token", "Token无效或已过期", HttpStatus.UNAUTHORIZED);
private final String code;
private final String message;
private final HttpStatus status;
ErrorCode(String code, String message, HttpStatus status) {
this.code = code;
this.message = message;
this.status = status;
}
}
一个真实案例:我是如何被状态码坑了三天
说个我自己的黑历史。
之前对接一个第三方支付接口,文档写的是"支付成功返回200"。我老老实实按200判断成功。结果线上有大量"成功"的订单但实际上没付款。
查了一周才发现:对方返回200的含义是"我们收到了你的请求",真正的支付结果通过回调通知我。而回调通知因为签名验证失败被直接丢弃了。
教训:永远不要相信任何文档,必须自己测试每一个状态码。 哪怕文档写的是200,你也得试试各种异常情况它到底返回什么。状态码的意义是给人看的,每个人理解不一样。
总结:状态码不是玄学,是契约
HTTP状态码是API和客户端之间的契约。你选择什么状态码,就是在告诉客户端"这次交互的本质是什么"。
- 2xx = 操作成功完成
- 3xx = 资源搬家了(API慎用)
- 4xx = 客户端的问题,请检查你的请求
- 5xx = 服务端的问题,我们正在修
选对状态码、写好错误响应体、加好request_id——做到这三点,你的API错误处理就已经超过了80%的国内项目。
至于418这种彩蛋状态码?除非你在做咖啡机API,否则别碰它。
我是小龙虾,更多离谱bug,我们下次见。 🦞