让用户抓狂的API错误,都长什么样

2026-07-04 4 0

让用户抓狂的API错误,都长什么样

你有没有过这种体验:打开一个App,点击按钮,然后屏幕弹出一个红彤彤的错误提示,写着"系统繁忙,请稍后再试"。你看了看时间,现在是下午三点,网络信号满格,你什么都没做错,但就是不知道发生了什么。

恭喜你,你刚刚体验了一次"教科书级别"的失败API错误处理。

错误处理是后端工程师的尊严

很多人以为后端工程师的核心能力是"写出能跑的接口"。大错特错。真正区分高级工程师和CURDboy的,是他们如何处理那些"不该发生"的事情。

接口正常返回数据,这不算本事。谁都能做到。真正的本事是:当数据库宕机了、当Redis超时了、当第三方接口返回了一堆鬼都看不懂的响应时,你的系统还能体面地告诉调用方发生了什么,以及——用户接下来该怎么办。

今天的文章,咱们就来聊聊那些让人血压飙升的错误处理方式,以及怎样才算"像个人一样"处理错误。

反面教材大赏:你是哪一种?

第一种:假装什么都没发生

try {
    doSomething();
} catch (Exception e) {
    // 吞掉异常,当作无事发生
}
return null;

这种代码在很多老项目里屡见不鲜。工程师的意思大概是:"我不知道怎么处理这个错误,但我也不想让它暴露出来。Return null吧,让调用方自己猜去。"

调用方拿到null,开始疯狂debug,最后发现是远处某个你写的catch块把异常吃了。这种"静默失败"模式,是生产环境中debug炼狱的标配。

第二种:错误信息全靠猜

{"code": 1001, "message": "操作失败"}

1001是什么?是用户不存在?还是权限不足?还是服务器脑子抽了?调用方只能靠命。

这种设计,你是在培养调用方的ESP(超感官知觉)能力。

第三种:把用户当工程师

{"code": 500, "message": "NullPointerException at com.example.service.UserService.getUser(UserService.java:45)"}

这是把生产日志直接暴露给用户。你以为用户在看到这段文字后会帮你debug?错了,他们只会打客服电话骂你,然后在应用商店给你打一星。

第四种:HTTP状态码和业务错误混为一谈

用一个200 OK返回"账户余额不足",然后在body里塞个code告诉你业务出错了。这是什么道理?HTTP状态码是给HTTP层看的,业务错误是给调用方看的,你不能用一个东西表达两个意思。

正确的姿势是什么?

一、错误码体系要分层

一个好的错误响应应该长这样:

{
  "code": "USER_NOT_FOUND",
  "message": "用户不存在",
  "detail": "根据ID 12345未找到对应用户记录",
  "requestId": "req_abc123xyz",
  "helpUrl": "https://api.example.com/docs/errors#USER_NOT_FOUND"
}

我来解释一下这个结构为什么这样设计:

code:业务错误码,机器可读。调用方可以根据这个做逻辑判断和国际化,而不是根据"操作失败"这种鬼话。

message:面向用户的简短描述。如果你不确定国际化怎么处理,至少给出一个人类能看懂的中文或英文。

detail:技术细节,给排查问题的人看。这个字段不应该出现在面向用户的界面,但应该在API响应里存在,方便排查。

requestId:请求追踪ID。这是灾难发生时,你能在日志系统里快速定位问题的唯一方式。没有requestId的错误响应,等于没有指纹的犯罪现场。

helpUrl:这是一个经常被忽视但极其有用的字段。它链接到文档,让调用方知道遇到这种错误应该怎么应对——是重试?是换参数?还是直接放弃治疗?

二、HTTP状态码要对

很多人懒得记HTTP状态码,反正返回200就完事了。但状态码是有语义的吗啡,你知道吗?

4xx系列:客户端的错误。是你调用方的问题,比如参数错误、权限不足、资源不存在。

5xx系列:服务端的错误。是我的问题,比如数据库宕机、代码bug、第三方服务超时。

这个区分有什么用?在于重试策略。4xx错误你重试一万次也不会变成对的,5xx错误可能重试几次就好了。

所以:

400 Bad Request        → 参数校验失败,用户的错,别重试
401 Unauthorized       → 没认证,别重试,去登录
403 Forbidden         → 没权限,别重试
404 Not Found         → 资源不存在,别重试
429 Too Many Requests  → 请求过于频繁,稍后重试
500 Internal Error     → 服务端抽风,可能重试能解决
502 Bad Gateway        → 网关错误,可能重试能解决
503 Service Unavailable → 服务不可用,稍后重试
504 Gateway Timeout    → 超时,可能重试能解决

三、错误要分级处理

不是所有错误都生而平等。有些错误你应该立即返回,有些错误你可以悄悄重试,有些错误你应该记录日志但不中断流程。

我的建议是,把错误分成三类:

致命错误:必须立即返回给调用方。比如参数校验失败、权限不足、资源不存在。这种错误不应该重试。

可重试错误:暂时性的故障,可能自己恢复。比如网络超时、第三方服务暂时不可用、数据库连接池耗尽。这种错误可以指数退避重试个三五次。

可忽略错误:对结果没有实质影响。比如记录审计日志失败、埋点数据上报失败。这种错误记录一下就行,不需要中断业务流程。

四、重试机制要聪明

说到重试,很多人写出来是这样的:

for (int i = 0; i < 3; i++) {
    try {
        return callRemoteService();
    } catch (Exception e) {
        Thread.sleep(1000); // 等一秒
    }
}

这个实现有三个问题:

固定间隔:如果远程服务从不可用变成可用需要5秒,你固定等1秒就是在无效空转。应该用指数退避,每次等待时间是上次的倍数。

无限重试:不管什么错都重试3次。但如果是业务参数错误,重试一万次也是白搭。

没有熔断:如果远程服务已经彻底宕机,你还在疯狂重试,就是在制造无效流量,拖垮自己的系统。

一个合格的重试逻辑应该长这样:

public Result callWithRetry(Callable<Result> operation) {
    int maxAttempts = 3;
    long baseDelay = 100; // 基础延迟100ms
    
    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            return operation.call();
        } catch (RetryableException e) {
            if (attempt == maxAttempts) throw e;
            long delay = baseDelay * (1L << (attempt - 1)); // 指数退避
            Thread.sleep(delay);
        } catch (FatalException e) {
            throw e; // 致命错误不重试
        }
    }
}

而且,配合熔断器(Circuit Breaker)使用效果更佳。当失败率超过阈值时,熔断器打开,后续请求直接返回"服务暂时不可用",不再做无效的远程调用。

五、记录可操作的日志

日志不是写的越多越好。很多人debug的时候喜欢打一堆info日志,结果出问题了一看,全是噪音。

好的错误日志应该包含:

  • :哪个服务、哪个实例
  • 什么:发生了什么错误,错误码是什么
  • 在哪:请求ID、调用链路
  • 上下文:关键参数、用户ID(注意脱敏)
  • 时间线:从请求进来到失败的每个节点的时间戳

而不是:

logger.info("开始处理请求");
logger.info("查询数据库");
logger.info("查询完成");
logger.error("出错了");

这种日志除了污染磁盘,没有任何价值。

一个实战例子:支付回调的正确处理

支付回调是错误处理的重灾区。我见过太多项目在这里翻车。典型场景:第三方支付完成,通知你的服务器。你需要更新订单状态。

常见的错误做法:

public void handleCallback(PayCallbackRequest request) {
    try {
        orderService.updateStatus(request.getOrderId(), "PAID");
    } catch (Exception e) {
        // 什么都不做,等第三方重试
    }
}

问题:如果你更新失败但没有返回正确响应,第三方会不断重试,最终可能导致重复扣款。或者你返回了成功但数据库根本没更新。

正确的做法:

public Response handleCallback(PayCallbackRequest request) {
    // 1. 幂等检查:这个回调我们处理过吗?
    if (idempotencyService.hasProcessed(request.getCallbackId())) {
        return Response.ok("success").build(); // 告诉第三方:知道了,别重试
    }
    
    // 2. 开启事务
    try {
        orderService.updateStatus(request.getOrderId(), "PAID");
        paymentService.recordCallback(request);
        return Response.ok("success").build();
    } catch (DuplicateOrderException e) {
        // 订单已经支付过了,幂等返回成功
        return Response.ok("success").build();
    } catch (Exception e) {
        // 记录日志,告警
        logger.error("支付回调处理失败", e);
        // 返回失败,让第三方稍后重试
        return Response.status(500).entity("retry later").build();
    }
}

核心思想:幂等是处理回调的命门。不管第三方通知多少次,你都要能正确处理且只处理一次。

总结一下

错误处理不是try-catch的艺术,它是系统设计的哲学。好的错误处理:

  • 让调用方知道发生了什么(code+message)
  • 让排查人员知道去哪里找(requestId+detail)
  • 让用户知道该怎么办(helpUrl+友好的提示)
  • 让系统知道什么时候该放弃(熔断+分级处理)
  • 让重试有意义(指数退避+只重试值得重试的错误)

下次写接口的时候,花三分钟想想:如果这里抛异常了,谁会看到?看到什么?怎么处理?

如果你能在写代码的时候就想到这些,那恭喜你,你已经不是CURDboy了。

下次见。

相关文章

连接池耗尽事故:finally块里的”安全代码”是怎么泄漏的
限流真不是加个计数器那么简单
OpenClaw 使用经验分享:一个话痨AI助手是怎么炼成的
API设计翻车实录:那些年我们一起踩过的坑
API设计翻车实录:那些年我们一起踩过的坑
为什么你的服务挂了,你却是最后一个知道的?——我见过最被低估的后端技能

发布评论