Filter Exceptions, WebFlux Error Handling, and the Gaps
The Filter Gap
The SaaS backend has a TenantResolutionFilter that extracts the tenant identifier from the X-Tenant-ID header and sets the tenant context for downstream processing. When the header is missing or the tenant does not exist, the filter throws:
@Component
public class TenantResolutionFilter extends OncePerRequestFilter {
private final TenantRepository tenantRepository;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId == null || tenantId.isBlank()) {
throw new MissingTenantHeaderException(
"X-Tenant-ID header is required");
}
Tenant tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new TenantNotFoundException(tenantId));
TenantContext.set(tenant);
try {
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}
This exception never reaches DispatcherServlet. The filter executes before the dispatcher. The servlet container catches the exception and forwards the request to /error. Spring Boot’s BasicErrorController handles /error and returns a generic error response.
The developer adds a @ControllerAdvice with @ExceptionHandler(MissingTenantHeaderException.class). It never fires. They add logging. Nothing prints. They add a catch-all @ExceptionHandler(Exception.class). Still nothing. The exception is not on the controller path. It is on the filter path. Different highway. Different destination.
Handling Filter Exceptions Properly
The filter must catch its own exceptions and write the response directly, or forward to a path that a controller can handle:
@Component
public class TenantResolutionFilter extends OncePerRequestFilter {
private final TenantRepository tenantRepository;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId == null || tenantId.isBlank()) {
writeError(response, HttpStatus.BAD_REQUEST,
"Missing Tenant", "X-Tenant-ID header is required");
return; // Do not continue the filter chain
}
Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
if (tenant == null) {
writeError(response, HttpStatus.NOT_FOUND,
"Tenant Not Found",
"No tenant found with ID: " + tenantId);
return;
}
TenantContext.set(tenant);
try {
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
private void writeError(HttpServletResponse response,
HttpStatus status, String title, String detail)
throws IOException {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
status, detail
);
problem.setTitle(title);
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), problem);
}
}
The key change: the filter catches its own error conditions, writes the response, and returns without calling filterChain.doFilter(). No exception propagates. No forwarding to /error. The client gets a structured ProblemDetail response.
An alternative approach delegates to the HandlerExceptionResolver chain manually:
@Component
public class TenantResolutionFilter extends OncePerRequestFilter {
private final HandlerExceptionResolver resolver;
public TenantResolutionFilter(
@Qualifier("handlerExceptionResolver")
HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId == null) {
throw new MissingTenantHeaderException(
"X-Tenant-ID header is required");
}
// ... tenant resolution logic
filterChain.doFilter(request, response);
} catch (Exception ex) {
resolver.resolveException(request, response, null, ex);
}
}
}
Inject the HandlerExceptionResolver (Spring registers a composite resolver under that bean name) and call it directly from the filter’s catch block. Now your @ExceptionHandler methods fire for filter exceptions. The handler parameter is null because there is no handler at this point, so @ControllerAdvice scoping based on assignableTypes will not work. Scoping based on basePackages or annotations also will not apply. Only unscoped @ControllerAdvice classes receive these exceptions.
ErrorController: The Last Stop
When an exception propagates through the entire filter chain to the servlet container, the container forwards the request to the configured error page. Spring Boot registers BasicErrorController mapped to /error:
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request,
HttpServletResponse response) {
// Returns the white-label error page
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(
HttpServletRequest request) {
// Returns JSON: {timestamp, status, error, path}
}
}
You can replace this with a custom ErrorController:
@Controller
@RequestMapping("/error")
public class TenantErrorController implements ErrorController {
@RequestMapping
public ResponseEntity<ProblemDetail> handleError(
HttpServletRequest request) {
Object status = request.getAttribute(
RequestDispatcher.ERROR_STATUS_CODE);
Object message = request.getAttribute(
RequestDispatcher.ERROR_MESSAGE);
Object exception = request.getAttribute(
RequestDispatcher.ERROR_EXCEPTION);
HttpStatus httpStatus = status != null
? HttpStatus.valueOf((int) status)
: HttpStatus.INTERNAL_SERVER_ERROR;
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
httpStatus,
message != null ? message.toString() : "Unexpected error"
);
problem.setInstance(URI.create(
(String) request.getAttribute(
RequestDispatcher.ERROR_REQUEST_URI)));
return ResponseEntity.status(httpStatus).body(problem);
}
}
This ensures consistent ProblemDetail responses even for exceptions that escape every other handler. The original exception is available through RequestDispatcher.ERROR_EXCEPTION, but do not expose its message to clients. Log it. Return a safe message.
WebFlux: ErrorWebExceptionHandler
WebFlux does not have servlet filters, DispatcherServlet, or /error forwarding. The entire error handling model is different.
Exceptions in WebFlux propagate as error signals through the reactive pipeline. At the outermost layer, ErrorWebExceptionHandler catches them:
@Component
@Order(-2)
public class SaasErrorWebExceptionHandler
implements ErrorWebExceptionHandler {
private final ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
return Mono.defer(() -> {
HttpStatus status;
ProblemDetail problem;
if (ex instanceof TenantNotFoundException tnf) {
status = HttpStatus.NOT_FOUND;
problem = ProblemDetail.forStatusAndDetail(
status, tnf.getMessage());
problem.setTitle("Tenant Not Found");
problem.setProperty("tenantId", tnf.getTenantId());
} else if (ex instanceof BusinessException bex) {
status = HttpStatus.UNPROCESSABLE_ENTITY;
problem = ProblemDetail.forStatusAndDetail(
status, bex.getMessage());
problem.setTitle("Business Rule Violation");
problem.setProperty("errorCode", bex.getErrorCode());
} else if (ex instanceof ResponseStatusException rse) {
status = HttpStatus.valueOf(
rse.getStatusCode().value());
problem = ProblemDetail.forStatusAndDetail(
status, rse.getReason());
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
problem = ProblemDetail.forStatusAndDetail(
status, "An unexpected error occurred");
problem.setTitle("Internal Server Error");
}
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders()
.setContentType(MediaType.APPLICATION_PROBLEM_JSON);
try {
byte[] bytes = objectMapper.writeValueAsBytes(problem);
DataBuffer buffer = exchange.getResponse()
.bufferFactory().wrap(bytes);
return exchange.getResponse()
.writeWith(Mono.just(buffer));
} catch (JsonProcessingException e) {
return Mono.error(e);
}
});
}
}
The @Order(-2) is critical. Spring Boot’s DefaultErrorWebExceptionHandler runs at order -1. Without a lower order number, your handler never sees the exception.
Within reactive handler methods, use onErrorResume and onErrorMap to transform errors at the operator level:
@RestController
@RequestMapping("/api/reactive/tenants/{tenantId}/orders")
public class ReactiveOrderController {
private final ReactiveOrderService orderService;
@PostMapping
public Mono<ResponseEntity<OrderResponse>> createOrder(
@PathVariable String tenantId,
@RequestBody CreateOrderRequest request) {
return orderService.create(tenantId, request)
.map(order -> ResponseEntity
.status(HttpStatus.CREATED)
.body(OrderResponse.from(order)))
.onErrorMap(
DuplicateKeyException.class,
ex -> new ResponseStatusException(
HttpStatus.CONFLICT,
"Order already exists"))
.onErrorResume(
SubscriptionExpiredException.class,
ex -> {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.PAYMENT_REQUIRED,
ex.getMessage());
return Mono.just(ResponseEntity
.status(HttpStatus.PAYMENT_REQUIRED)
.body(null));
});
}
}
onErrorMap transforms one exception type into another. onErrorResume replaces the error signal with a fallback value. Use onErrorMap when you want a different exception to reach the ErrorWebExceptionHandler. Use onErrorResume when you want to handle the error locally and return a response.
@Async: The Silent Swallower
@Async methods that return void are the quietest exception killers in Spring. The framework dispatches the method to a thread pool executor. If the method throws, the executor catches the exception. With no AsyncUncaughtExceptionHandler configured, the exception is logged at ERROR level by SimpleAsyncUncaughtExceptionHandler and discarded. No error response. No retry. No alert.
// BROKEN: Exception in @Async void method disappears.
// SimpleAsyncUncaughtExceptionHandler logs it and moves on.
// The caller never knows the notification failed.
@Service
public class NotificationService {
@Async
public void sendNotification(String tenantId, String message) {
Tenant tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new TenantNotFoundException(tenantId));
// This exception is caught by the executor,
// logged once, and forgotten.
emailClient.send(tenant.getAdminEmail(), message);
}
}
The calling code:
// Caller has no way to know this failed
notificationService.sendNotification(tenantId, "Order created");
The notification silently fails. The executor thread logs the exception. Nobody checks those logs until a customer reports they stopped receiving emails. By then, hundreds of notifications are lost.
// CORRECT: Option 1 - Configure AsyncUncaughtExceptionHandler
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
private final MeterRegistry meterRegistry;
private final AlertService alertService;
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("Async method {} failed with params {}",
method.getName(), Arrays.toString(params), ex);
// Increment a metric so monitoring catches it
meterRegistry.counter("async.errors",
"method", method.getName(),
"exception", ex.getClass().getSimpleName()
).increment();
// Alert for critical methods
if (method.getName().equals("sendNotification")) {
alertService.critical(
"Notification delivery failed", ex);
}
};
}
}
// CORRECT: Option 2 - Return CompletableFuture instead of void.
// The caller can observe failures.
@Service
public class NotificationService {
@Async
public CompletableFuture<NotificationResult> sendNotification(
String tenantId, String message) {
Tenant tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new TenantNotFoundException(tenantId));
emailClient.send(tenant.getAdminEmail(), message);
return CompletableFuture.completedFuture(
new NotificationResult(tenantId, true));
}
}
With CompletableFuture, the caller can attach error handlers:
notificationService.sendNotification(tenantId, "Order created")
.exceptionally(ex -> {
log.error("Notification failed for tenant {}", tenantId, ex);
retryQueue.enqueue(tenantId, "Order created");
return new NotificationResult(tenantId, false);
});
The exception is no longer silent. It is observable, measurable, and recoverable.
The Complete Exception Map
Every Spring Boot application has five distinct exception paths:
| Source | Caught By | Handler Configuration |
|---|---|---|
| Controller method | DispatcherServlet | @ExceptionHandler in @ControllerAdvice |
| Servlet filter | Servlet container | Direct response writing or ErrorController |
| Security filter | ExceptionTranslationFilter | AccessDeniedHandler, AuthenticationEntryPoint |
| WebFlux handler | Reactive pipeline | ErrorWebExceptionHandler, @ExceptionHandler |
@Async void method | Thread pool executor | AsyncUncaughtExceptionHandler |
When an exception disappears, identify the source. The source determines the path. The path determines which handler configuration applies. Attaching @ExceptionHandler to a filter exception is the same as putting a mailbox on the wrong street. The address is correct. The street is wrong. The mail never arrives.