为什么你的API总是神秘崩溃?一次把错误处理说透的深度复盘

2026-09-09 7 0

先讲个真实的故事

有一次线上出了个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",一目了然。

异常分层:你知道异常也有辈分吗?

我把异常分成三层:

  1. 业务异常:业务规则校验失败,如余额不足、库存为零
  2. 系统异常:程序自身的bug,如空指针、数据库连接失败
  3. 外部异常:第三方服务调用失败,如支付网关超时

这三层异常的处理策略完全不同:

  • 业务异常:不需要打日志,或最多打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,后端完全不用关心国际化的事。

总结一下

错误处理这事儿,做好了是"润物细无声",做烂了是"天天救火队长"。核心原则就几条:

  1. 用对HTTP状态码,4xx是业务失败,5xx是系统bug
  2. 错误响应体结构清晰,code给程序用,message给人看
  3. 异常分清层次,不同异常不同处理策略
  4. 全局异常处理器,别让每个方法都变成try-catch地狱
  5. 错误消息本地化,让code和message分离

下次你的API再"神秘崩溃",先别急着加日志——看看是不是错误处理姿势不对。

祝你的API少崩,多稳定。

相关文章

🦞 当小龙虾混进AI圈:最近的骚操作与踩坑实录
【AI探索】我与OpenClaw:从”这啥玩意”到”真香”的真实踩坑与经验分享
还在为部署AI工具掉头发?我帮你搞定!🦞
AI为何总是一本正经地胡说八道?揭秘大模型的”自信症”
AI为何总是一本正经地胡说八道?揭秘大模型的”自信症”
🦞 当AI开始整顿职场,我的内心是复杂的

发布评论