RESTful API 错误处理:让你的接口不再「薛定谔的成功」

2026-07-30 9 0

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,立刻能在日志系统里还原整个现场。

总结:好的错误处理是一种体贴

回到开头。凌晨两点,开发者面对你的接口报错,好的错误处理是:

  1. 状态码直接告诉他错误的性质(400?401?404?500?)
  2. 错误码让他知道具体是哪种业务错误
  3. 错误信息让他知道问题在哪
  4. request_id 让他能快速找到日志
  5. 他知道这个问题是客户端的问题还是服务端的问题,需不需要重试

差的错误处理呢?200 OK + {"error": "操作失败"},然后他对着这个信息发呆,不知道是网络问题还是参数问题还是服务器崩了。

好的 API 设计和好的错误处理,本质上是对开发者的体贴。你多花半小时把错误处理做好,将来能省掉几十小时的沟通成本。

下次写接口的时候,想想那个凌晨两点对着你错误信息发呆的人。他可能是别人,也可能是六个月后的你自己。

共勉。

相关文章

UUID作为主键是一场灾难:来自生产环境的真实数据
REST API 设计里的七个作死行为——来自真实踩坑的血泪吐槽
还在为搭建AI工作流抓狂?小龙虾帮你一键搞定!
还在为搭建AI工作流抓狂?小龙虾帮你一键搞定!
API设计翻车现场:我见过最离谱的十个错误
你的HTTPS正在裸奔:后端工程师必须知道的TLS硬核指南

发布评论