默认错误响应的局限
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 采用分层错误码:
| 层级 | 范围 | 示例 |
|---|---|---|
| 客户端错误 | 4xxx | 4001 参数缺失,4002 参数格式错误,4003 参数校验失败,4004 请求体过大 |
| 认证授权 | 41xx | 4101 未登录,4102 Token 过期,4103 权限不足 |
| 资源操作 | 42xx | 4201 资源不存在,4202 资源已存在,4203 资源被锁定 |
| 服务端错误 | 5xxx | 5001 内部错误,5002 数据库错误,5003 下游服务超时 |
| 业务错误 | 6xxx | 6001 余额不足,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 响应式安全 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。