RESTful API 错误处理:让你的接口不再「薛定谔的成功」
想象一下这个场景:你调用了一个 API,等了三秒,服务器返回了 200 OK。你松了口气开始解析数据,结果发现返回的是个错误信息,只是 HTTP 状态码被设为 200。这种「成功」的错误,就是我说的「薛定谔的成功」——你不知道它到底是成功还是失败,除非你打开看了。
这是我在多年后端开发中见过的最普遍也最恶心的反模式。今天咱们就来聊聊,如何把 API 错误处理做正经了。
为什么你的错误处理一塌糊涂
先说个暴论:大多数团队的错误处理烂,不是因为不知道怎么写,而是从一开始就没想清楚错误处理的本质是什么。
错误处理的本质是通信。你的 API 不只是给程序看的,更是给调试的程序员看的。当一个移动端开发者凌晨两点发现支付接口报错了,他第一时间看的是你的错误信息,而不是你的代码。所以你的错误信息应该是这样的:
- 清晰:这个错误是什么
- 准确:为什么会出现这个错误
- 可操作:开发者应该怎么修复
- 一致:所有错误都遵循同一套逻辑
状态码:你以为你用对了,其实没有
先问个问题:你的登录接口,用户名密码错了,返回什么?
我见过的主流答案:200 OK(带一个 {"code": 1001, "msg": "密码错误"}),还有 400 Bad Request,还有 401 Unauthorized。
只有最后一个是对的,但用它的人最少。为什么?因为大家觉得「401 是未授权,我的接口需要登录才能访问才是401」,对吧?
错。401 的定义是「认证失败」,即使用了错误的凭证。注意是「认证」(Authentication),不是「授权」(Authorization)。密码错误就是认证失败,返回 401 天经地义。
那什么时候用 400 Bad Request?格式错误的时候。比如参数缺失、类型不对、JSON 解析失败。用户的请求本身是有问题的,你连业务逻辑都没到就失败了。
来看个标准的状态码使用指南:
// 客户端错误 - 4xx 系列
400 Bad Request - 请求格式/参数有误
401 Unauthorized - 认证失败(密码错、Token无效)
403 Forbidden - 无权限访问该资源
404 Not Found - 资源不存在
409 Conflict - 资源冲突(如重复创建)
422 Unprocessable - 语义错误,请求格式对但逻辑不对
429 Too Many Requests - 请求过于频繁
// 服务器错误 - 5xx 系列
500 Internal Server Error - 服务器崩了
502 Bad Gateway - 依赖的外部服务跪了
503 Service Unavailable - 服务暂时不可用
504 Gateway Timeout - 依赖服务超时
记住一个原则:能用状态码说清楚的,就不要用业务码。HTTP 状态码是 RFC 标准,开发者都认识。你的 {"code": 10001} 他得查文档才知道是啥。
错误响应体:一个格式走天下
现在很多团队的错误响应体是这么写的:
// 情况1
{"error": "用户不存在"}
// 情况2
{"code": 40401, "message": "用户不存在"}
// 情况3
{"status": 0, "msg": "用户不存在", "data": null}
// 情况4(恭喜你,中了)
{"code": 200, "message": "success", "data": {"error": "用户不存在"}}
情况4那个不是我编的,真有团队这么写。JSON 里套 JSON,成功失败都用200。你品,你细品。
我的建议是统一错误响应体格式,这样客户端好处理:
{
"success": false,
"error": {
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"detail": "在数据库中未找到ID为 12345 的用户记录",
"request_id": "req_abc123xyz"
}
}
success 字段用布尔值而不是数字,客户端 if (!response.success) 就够了。error 对象里包含:
code:机器可读的错误码,便于客户端 switch 处理message:给开发者看的简短描述detail:可选的详细说明,有时候有用request_id:请求追踪ID,出问题找日志全靠它
成功的时候长这样:
{
"success": true,
"data": {
"id": 12345,
"name": "张三",
"email": "zhangsan@example.com"
}
}
简单明了。
异常层次结构:别一股脑都抛500
我见过太多 Controller 写成这样:
@PostMapping("/users")
public ResponseEntity> createUser(@RequestBody UserRequest request) {
try {
// 业务逻辑
User user = userService.create(request);
return ResponseEntity.ok(user);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
}
不管什么错都500。数据库连接失败500,用户已存在500,参数校验失败还是500。请问客户端怎么区分?重试机制怎么写?
正确的做法是建立异常层次结构:
// 基础业务异常
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
}
// 具体业务异常
public class UserNotFoundException extends BusinessException {
public UserNotFoundException(Long userId) {
super("USER_NOT_FOUND", "用户不存在: " + userId);
}
}
public class DuplicateUserException extends BusinessException {
public DuplicateUserException(String email) {
super("DUPLICATE_USER", "用户邮箱已存在: " + email);
}
}
// 参数校验异常
public class ValidationException extends BusinessException {
public ValidationException(String field, String message) {
super("VALIDATION_ERROR", "参数校验失败 [" + field + "]: " + message);
}
}
然后在全局异常处理器里统一处理:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity handleUserNotFound(UserNotFoundException e) {
return ResponseEntity.status(404)
.body(ErrorResponse.of("USER_NOT_FOUND", e.getMessage()));
}
@ExceptionHandler(DuplicateUserException.class)
public ResponseEntity handleDuplicateUser(DuplicateUserException e) {
return ResponseEntity.status(409)
.body(ErrorResponse.of("DUPLICATE_USER", e.getMessage()));
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity handleValidation(ValidationException e) {
return ResponseEntity.status(400)
.body(ErrorResponse.of("VALIDATION_ERROR", e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity handleGeneral(Exception e) {
// 记录日志,返回通用错误
log.error("Unexpected error", e);
return ResponseEntity.status(500)
.body(ErrorResponse.of("INTERNAL_ERROR", "服务器内部错误"));
}
}
这样每种错误都有明确的处理逻辑和状态码,客户端也容易适配。
参数校验:防御的第一道墙
很多人把参数校验放在业务逻辑里:
public User create(UserRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
throw new ValidationException("name不能为空");
}
if (request.getEmail() != null && !request.getEmail().contains("@")) {
throw new ValidationException("email格式不正确");
}
// 业务逻辑...
}
这种方式的问题是:校验逻辑和业务逻辑混在一起,而且校验失败返回什么状态码全凭心情。
用 Bean Validation 吧:
public class UserRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 50, message = "用户名长度2-50字符")
private String name;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能超过150")
private Integer age;
}
配合全局异常处理:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.status(400)
.body(ErrorResponse.of("VALIDATION_ERROR", message));
}
干净多了。
日志和追踪:错误现场的保护伞
最后说个很多人忽视的点:错误发生时的日志记录。
很多团队的日志是这样的:
try {
// 操作
} catch (Exception e) {
log.error("操作失败", e);
}
听起来没问题。但是当生产环境出问题,运维找你,你说「用户下单失败了」,然后呢?
你需要知道的是:这个用户是谁、哪个订单、操作了什么、调用链是什么、关联的外部服务是什么。
正确的日志应该包含上下文:
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity handleUserNotFound(UserNotFoundException e, HttpServletRequest request) {
String requestId = UUID.randomUUID().toString();
log.error("用户不存在 | requestId: {} | userId: {} | ip: {} | path: {}",
requestId,
extractUserIdFromException(e),
request.getRemoteAddr(),
request.getRequestURI());
return ResponseEntity.status(404)
.body(ErrorResponse.of("USER_NOT_FOUND", "用户不存在", requestId));
}
这样当用户反馈「我下单失败了」,你只需要他的 request_id,立刻能在日志系统里还原整个现场。
总结:好的错误处理是一种体贴
回到开头。凌晨两点,开发者面对你的接口报错,好的错误处理是:
- 状态码直接告诉他错误的性质(400?401?404?500?)
- 错误码让他知道具体是哪种业务错误
- 错误信息让他知道问题在哪
- request_id 让他能快速找到日志
- 他知道这个问题是客户端的问题还是服务端的问题,需不需要重试
差的错误处理呢?200 OK + {"error": "操作失败"},然后他对着这个信息发呆,不知道是网络问题还是参数问题还是服务器崩了。
好的 API 设计和好的错误处理,本质上是对开发者的体贴。你多花半小时把错误处理做好,将来能省掉几十小时的沟通成本。
下次写接口的时候,想想那个凌晨两点对着你错误信息发呆的人。他可能是别人,也可能是六个月后的你自己。
共勉。