先讲个真实的故事
有一次线上出了个bug,用户下单后订单状态一直是"处理中",永远不变。查日志,发现有个接口返回了500,但这个500被前端默默吞掉了——或者说,前端以为"没报错就是成功了"。
这不是代码烂的问题,这是整个团队对"错误处理"这件事的认知就有问题。
今天我们就来好好聊聊,API错误处理那些事儿。
你的HTTP状态码用对了吗?
很多人写API,错误一律返回200,然后在body里塞个code: 500。我理解这么想——"万一前端框架对非200状态码有什么奇怪的处理呢"。但这种"保守"恰恰是万恶之源。
HTTP状态码是干嘛用的?是机器读的吗?是,但更是给人读的。当你的日志里躺着一条200 OK但业务其实是失败的,这种200比500还难查。
正确的状态码使用姿势:
- 400 Bad Request:请求参数校验失败,语义清晰
- 401 Unauthorized:没登录或token过期,别用403代替
- 403 Forbidden:登录了但没权限访问这个资源
- 404 Not Found:资源不存在,注意"不存在"和"无权访问"是两码事
- 422 Unprocessable Entity:语义级校验失败(比如邮箱格式对但不存在于我们的用户表里)
- 429 Too Many Requests:请求过于频繁,这个你可能没用过但很实用
- 500 Internal Server Error:真正的服务端bug,不是"业务逻辑失败"
记住:500是留给程序员的,不是留给业务校验的。你的业务逻辑失败请用4xx。
错误响应体设计:别让前端猜谜
很多项目的错误响应体是这样的:
{ "error": "操作失败", "message": "系统异常"}
然后前端工程师开始猜:"这个error是给用户看的还是给日志看的?message和error有什么区别?我应该显示哪个?"
来,我给你一个经过血泪验证的错误响应结构:
{ "code": "ORDER_NOT_FOUND", "message": "订单不存在", "detail": "订单号ORD20240815001对应的订单状态为已取消,无法进行支付", "requestId": "req_abc123xyz", "timestamp": 1723228800}
解释一下每个字段的含义:
- code:业务错误码,字符串类型,用于前端做逻辑分支判断
- message:给用户看的简短提示,要本地化
- detail:给开发者看的详细信息,包括上下文
- requestId:关联一次请求的全局ID,查日志必备
- timestamp:时间戳,防止时间混乱
重点说下code。很多团队用数字错误码,我建议用字符串。为啥?数字code在跨团队、跨文档传递时容易丢失语义,"1002"是什么?没人记得住。但"ORDER_NOT_FOUND",一目了然。
异常分层:你知道异常也有辈分吗?
我把异常分成三层:
- 业务异常:业务规则校验失败,如余额不足、库存为零
- 系统异常:程序自身的bug,如空指针、数据库连接失败
- 外部异常:第三方服务调用失败,如支付网关超时
这三层异常的处理策略完全不同:
- 业务异常:不需要打日志,或最多打INFO级别,因为这是"正常流程的一部分"
- 系统异常:必须打ERROR日志,需要告警,可能需要自动补偿
- 外部异常:需要打WARN日志,因为可能是临时的网络问题,有必要做重试
但我见过最常见的做法是:所有异常一律打ERROR,然后发一堆告警,最后大家麻木了,告警邮件看都不看。这就像"狼来了"——当你把所有异常都当作紧急情况处理,其实就是没有紧急情况。
全局异常处理器:告别try-catch地狱
假设你在写Java(Spring生态),一个典型的Controller方法可能是这样的:
@PostMapping("/orders")public Result<Order> createOrder(CreateOrderRequest request) { try { return Result.success(orderService.create(request)); } catch (BusinessException e) { return Result.fail(e.getCode(), e.getMessage()); } catch (Exception e) { log.error("创建订单失败", e); return Result.fail("SYSTEM_ERROR", "系统异常"); }}
一个方法里塞三个try-catch,看起来很"全面",但问题是:每个方法都这么写,你会疯的。
正确做法是用全局异常处理器:
@RestControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result<?> handleBusiness(BusinessException e) { return Result.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(ExternalServiceException.class) public Result<?> handleExternal(ExternalServiceException e) { log.warn("外部服务调用失败: {}", e.getServiceName(), e); return Result.fail("EXTERNAL_SERVICE_ERROR", "服务暂不可用"); } @ExceptionHandler(Exception.class) public Result<?> handleGeneral(Exception e) { log.error("系统异常", e); return Result.fail("SYSTEM_ERROR", "系统异常,请稍后重试"); }}
这样每个Controller方法都可以是干净的,只有业务逻辑,没有异常处理。异常处理逻辑集中在一处,修改和扩展都方便。
错误播报:用户看到的是什么?
最后聊一个被忽视的环节:错误消息的本地化。
你的后端返回的message是英文还是中文?很多项目一开始用英文,后来加了中文用户,就在代码里一堆if-else判断语言。更好的做法是:错误码和错误消息模板分开存储:
// 错误码定义public enum ErrorCode { ORDER_NOT_FOUND("ORDER_NOT_FOUND", "order not found"), BALANCE_INSUFFICIENT("BALANCE_INSUFFICIENT", "insufficient balance"); // ...}// 本地化消息存储// messages_zh.properties// ORDER_NOT_FOUND=订单不存在// BALANCE_INSUFFICIENT=余额不足
这样后端只需要返回errorCode,前端或中台根据errorCode和用户语言去取对应的message,后端完全不用关心国际化的事。
总结一下
错误处理这事儿,做好了是"润物细无声",做烂了是"天天救火队长"。核心原则就几条:
- 用对HTTP状态码,4xx是业务失败,5xx是系统bug
- 错误响应体结构清晰,code给程序用,message给人看
- 异常分清层次,不同异常不同处理策略
- 全局异常处理器,别让每个方法都变成try-catch地狱
- 错误消息本地化,让code和message分离
下次你的API再"神秘崩溃",先别急着加日志——看看是不是错误处理姿势不对。
祝你的API少崩,多稳定。