5.3 错误契约

WebFlux 统一错误处理体系:@ControllerAdvice 与 @ExceptionHandler 全局异常捕获、自定义 ErrorResponse 体与分层错误码设计、HTTP 状态码映射、函数式路由错误处理、响应式链错误传播与降级模式。

默认错误响应的局限

Spring WebFlux 默认的错误 JSON 长这样:

{
  "timestamp": "2026-11-05T06:17:00.000+00:00",
  "path": "/api/orders/999",
  "status": 500,
  "error": "Internal Server Error",
  "requestId": "a1b2c3d4-5"
}

问题:

  • message 默认不返回(安全设置 server.error.include-message=never),客户端拿不到失败原因
  • requestId 是 ServerHttpRequest.getId() 生成的内部 ID,不是业务请求 ID,跨服务追踪时对不上
  • 没有应用级错误码,前端无法程序化判断错误类型
  • timestamp 格式不便解析
  • 不同异常返回同样的 500 结构,无法区分「参数校验失败」和「数据库连接断开」

@ControllerAdvice 全局异常处理

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import reactor.core.publisher.Mono;

import java.time.Instant;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public Mono<ResponseEntity<ErrorResponse>> handleBadRequest(IllegalArgumentException ex) {
        return Mono.just(ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse(
                "INVALID_ARGUMENT",
                ex.getMessage(),
                HttpStatus.BAD_REQUEST.value(),
                Instant.now().toEpochMilli()
            )));
    }

    @ExceptionHandler(NotFoundException.class)
    public Mono<ResponseEntity<ErrorResponse>> handleNotFound(NotFoundException ex) {
        return Mono.just(ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse(
                "RESOURCE_NOT_FOUND",
                ex.getMessage(),
                HttpStatus.NOT_FOUND.value(),
                Instant.now().toEpochMilli()
            )));
    }

    @ExceptionHandler(Exception.class)
    public Mono<ResponseEntity<ErrorResponse>> handleGeneric(Exception ex) {
        return Mono.just(ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse(
                "INTERNAL_ERROR",
                "An unexpected error occurred",
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                Instant.now().toEpochMilli()
            )));
    }
}

ErrorResponse 记录:

public record ErrorResponse(
    String code,      // 应用级错误码,前端判断用
    String message,   // 人类可读描述
    int status,       // HTTP 状态码
    long timestamp    // Unix 毫秒时间戳
) {}

错误码设计

eventd v2 采用分层错误码:

层级范围示例
客户端错误4xxx4001 参数缺失,4002 参数格式错误,4003 参数校验失败,4004 请求体过大
认证授权41xx4101 未登录,4102 Token 过期,4103 权限不足
资源操作42xx4201 资源不存在,4202 资源已存在,4203 资源被锁定
服务端错误5xxx5001 内部错误,5002 数据库错误,5003 下游服务超时
业务错误6xxx6001 余额不足,6002 库存不足,6003 重复提交

自定义异常携带错误码:

public class BusinessException extends RuntimeException {
    private final String code;
    private final int httpStatus;

    public BusinessException(String code, String message, int httpStatus) {
        super(message);
        this.code = code;
        this.httpStatus = httpStatus;
    }
    // getters...
}

// 使用
throw new BusinessException("6001", "Insufficient balance", 400);

对应的 handler:

@ExceptionHandler(BusinessException.class)
public Mono<ResponseEntity<ErrorResponse>> handleBusiness(BusinessException ex) {
    return Mono.just(ResponseEntity
        .status(ex.getHttpStatus())
        .body(new ErrorResponse(
            ex.getCode(),
            ex.getMessage(),
            ex.getHttpStatus(),
            Instant.now().toEpochMilli()
        )));
}

函数式路由的错误处理

函数式路由没有 @ControllerAdvice,错误处理在 RouterFunction 层面:

@Bean
public RouterFunction<ServerResponse> apiRoutes(OrderHandler handler) {
    RouterFunction<ServerResponse> routes = RouterFunctions.route()
        .GET("/api/orders/{id}", handler::get)
        .build();

    return routes.filter((request, next) ->
        next.handle(request)
            .onErrorResume(NotFoundException.class, ex ->
                ServerResponse.status(404)
                    .bodyValue(new ErrorResponse("4201", ex.getMessage(), 404, now())))
            .onErrorResume(IllegalArgumentException.class, ex ->
                ServerResponse.status(400)
                    .bodyValue(new ErrorResponse("4001", ex.getMessage(), 400, now())))
            .onErrorResume(Exception.class, ex ->
                ServerResponse.status(500)
                    .bodyValue(new ErrorResponse("5001", "Internal error", 500, now())))
    );
}

static long now() { return Instant.now().toEpochMilli(); }

响应式链中的错误传播

在 Mono/Flux 链中抛出的异常,会被 Reactor 捕获并转为 onError 信号:

@GetMapping("/api/risky")
public Mono<String> risky() {
    return Mono.just("start")
        .flatMap(s -> callExternalService())     // 可能抛异常
        .flatMap(result -> process(result))       // 可能抛异常
        .onErrorResume(ExternalServiceException.class, ex -> {
            // 降级:返回缓存值
            return Mono.just("cached-result");
        })
        .onErrorResume(err -> {
            // 最终兜底
            return Mono.error(new BusinessException("5001", "All failed", 500));
        });
}

错误信号在响应式链中向下游传播,直到被 onErrorResume、onErrorReturn 或 @ExceptionHandler 捕获。

错误日志与追踪

错误发生时需要记录足够上下文:

@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public Mono<ResponseEntity<ErrorResponse>> handle(Exception ex, ServerWebExchange exchange) {
        String requestId = (String) exchange.getAttribute("requestId");
        String path = exchange.getRequest().getURI().getPath();

        // 交给 SLF4J/结构化日志,不要用 printStackTrace 直接写 stderr
        log.error("request failed requestId={} path={} error={}",
            requestId, path, ex.getClass().getSimpleName(), ex);

        return Mono.just(ResponseEntity.status(500)
            .body(new ErrorResponse("5001", "Internal error", 500, now())));
    }
}

请求 ID(第 4.3 节)贯穿异常处理,便于在日志中追踪完整请求链路。

验证错误(@Valid)

结合 @Valid 做参数校验,校验失败自动转为结构化错误:

public record CreateOrderRequest(
    @NotBlank String symbol,
    @Min(1) int quantity,
    @Positive double price
) {}

@PostMapping("/orders")
public Mono<Order> create(@Valid @RequestBody CreateOrderRequest request) {
    return save(request);
}

校验失败抛 WebExchangeBindException(注意类名是 Bind,不是 Binding),它直接实现 BindingResult,可以读取全部校验错误:

@ExceptionHandler(WebExchangeBindException.class)
public Mono<ResponseEntity<ErrorResponse>> handleValidation(WebExchangeBindException ex) {
    String details = ex.getAllErrors().stream()
        .map(ObjectError::getDefaultMessage)
        .collect(Collectors.joining(", "));

    return Mono.just(ResponseEntity.status(400)
        .body(new ErrorResponse("4003", "Validation failed: " + details, 400, now())));
}

@Valid @RequestBody Mono<T> 也是可用的:WebFlux 先解码出元素,再在 doOnNext 里对元素执行校验,失败同样抛 WebExchangeBindException。但 @Valid 必须写在参数上;写成 Mono<@Valid CreateOrderRequest> 这种泛型参数上的注解不会被参数解析器读取,等于没加。如果不想依赖这个隐式行为,也可以在业务链里手动调用 Validator,把结果转成 WebExchangeBindException。

请求体超过 codec 内存上限(第 5.1 节的 maxInMemorySize)时抛的是 DataBufferLimitException,它不继承 ServerWebInputException,需要单独映射:

import org.springframework.core.io.buffer.DataBufferLimitException;

@ExceptionHandler(DataBufferLimitException.class)
public Mono<ResponseEntity<ErrorResponse>> handleTooLarge(DataBufferLimitException ex) {
    return Mono.just(ResponseEntity
        .status(HttpStatus.PAYLOAD_TOO_LARGE)
        .body(new ErrorResponse("4004", "Request body too large", 413, now())));
}

eventd v2 错误契约

eventd v2 的错误响应体沿用上面的四字段结构,请求 ID 和路径放在响应头和日志里,不重复塞进 body:

{
  "code": "6001",
  "message": "Insufficient balance",
  "status": 400,
  "timestamp": 1700000000000
}

响应头带 X-Request-ID: abc123,日志里同时记录 requestId 和 path。这样 body 的字段与 ErrorResponse record 一一对应,前端解析稳定,追踪信息也不会因为 body 结构变化而丢失。

小结

能力方式注意
全局异常捕获@RestControllerAdvice + @ExceptionHandler注解路由用
函数式路由异常.filter() 中 onErrorResume按需分类处理
自定义错误码BusinessException 携带 code + httpStatus分层设计便于前端判断
链内降级onErrorResume / onErrorReturn读场景安全,写场景慎用
验证错误@Valid + WebExchangeBindException handler@Valid 写在 Mono<T> 参数上会校验解码后的元素
上下文记录ServerWebExchange.getAttribute("requestId")错误日志中保留追踪 ID

第 5 章结束。下一章进入安全:6.1 响应式安全 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南