让用户抓狂的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了。
下次见。