Exception Handling Internals: @ExceptionHandler, ResponseEntityExceptionHandler, and Where Errors Go When They Disappear
The Contract
Every Spring MVC application has exactly one place where controller exceptions become HTTP responses: the HandlerExceptionResolver chain inside DispatcherServlet. If you do not understand this chain, you will write exception handlers that never fire, catch exceptions in the wrong layer, and ship APIs that return HTML stack traces to JSON clients.
Here is the path an exception travels. A controller method throws. DispatcherServlet.processHandlerException() catches it. It iterates an ordered list of HandlerExceptionResolver beans. The first resolver that returns a non-null ModelAndView wins. The rest never see the exception. If no resolver handles it, the exception propagates to the servlet container.
public interface HandlerExceptionResolver {
ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
);
}
One interface. Four parameters. The return type is ModelAndView, even for REST APIs. When your @ExceptionHandler returns a ResponseEntity, the framework wraps the response-writing logic in a ModelAndView with a null view and marks the response as already handled. The abstraction leaks. It has always leaked. You need to know this so you stop wondering why REST error handlers interact with view resolution.
The Three Default Resolvers
Spring Boot registers three HandlerExceptionResolver implementations in this order:
1. ExceptionHandlerExceptionResolver (order 0). This is the resolver that finds and invokes @ExceptionHandler methods. It searches the throwing controller first for a local @ExceptionHandler, then scans all @ControllerAdvice classes for a match. It uses the exception type hierarchy: a handler for RuntimeException catches IllegalArgumentException unless a more specific handler exists.
2. ResponseStatusExceptionResolver (order 1). This handles exceptions annotated with @ResponseStatus. It calls response.sendError(statusCode, reason), which triggers the servlet container’s error page mechanism.
3. DefaultHandlerExceptionResolver (order 2). This handles Spring’s own exceptions. HttpRequestMethodNotSupportedException becomes 405. MissingServletRequestParameterException becomes 400. NoHandlerFoundException becomes 404. There are roughly 15 mappings. This resolver exists so Spring’s internal exceptions translate to correct HTTP status codes without requiring user configuration.
The iteration is strict first-match. Once ExceptionHandlerExceptionResolver handles an exception through your @ExceptionHandler method, the other two resolvers never see it. If your handler throws its own exception during processing, that secondary exception replaces the original. You lose the cause.
@ControllerAdvice: Global Exception Handling
@ControllerAdvice classes are Spring-managed beans annotated to provide cross-cutting concerns for controllers. For exception handling, they hold @ExceptionHandler methods that apply to multiple controllers.
In the SaaS backend, the global error handler looks like this:
@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantAwareExceptionHandler {
@ExceptionHandler(TenantNotFoundException.class)
public ResponseEntity<ProblemDetail> handleTenantNotFound(
TenantNotFoundException ex,
HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
ex.getMessage()
);
problem.setTitle("Tenant Not Found");
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("tenantId", ex.getTenantId());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
@ExceptionHandler(InsufficientQuotaException.class)
public ResponseEntity<ProblemDetail> handleQuotaExceeded(
InsufficientQuotaException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.PAYMENT_REQUIRED,
"Tenant has exceeded its allocated quota"
);
problem.setTitle("Quota Exceeded");
problem.setProperty("currentUsage", ex.getCurrentUsage());
problem.setProperty("limit", ex.getLimit());
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(problem);
}
}
@ControllerAdvice Scoping
@ControllerAdvice accepts attributes that restrict which controllers it applies to:
// Only applies to controllers in this package
@ControllerAdvice(basePackages = "com.saas.billing")
public class BillingExceptionHandler { ... }
// Only applies to controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class RestExceptionHandler { ... }
// Only applies to controllers assignable to this type
@ControllerAdvice(assignableTypes = TenantController.class)
public class TenantSpecificHandler { ... }
Scoping matters in the SaaS backend because billing errors and tenant management errors need different response formats. The billing module returns detailed invoice information in error responses. The tenant management module returns tenant context. Without scoping, a single @ControllerAdvice must inspect the request path to decide which format to use. That is fragile.
ProblemDetail: RFC 9457 in Spring 6
Spring 6 introduced native ProblemDetail support. This implements RFC 9457 (formerly RFC 7807), which defines a standard JSON structure for HTTP error responses:
{
"type": "https://api.saas-platform.com/errors/quota-exceeded",
"title": "Quota Exceeded",
"status": 402,
"detail": "Tenant has exceeded its allocated quota",
"instance": "/api/tenants/t-42/orders",
"currentUsage": 10000,
"limit": 10000
}
To enable ProblemDetail responses for Spring’s built-in exceptions, set:
spring.mvc.problemdetail.enabled=true
This changes DefaultHandlerExceptionResolver behavior. Instead of calling response.sendError() with bare status codes, it creates ProblemDetail objects. A MethodArgumentNotValidException on a validation failure now returns structured JSON instead of a generic 400.
ResponseEntityExceptionHandler: The Base Class
ResponseEntityExceptionHandler is an abstract @ControllerAdvice that handles all Spring MVC exceptions with ProblemDetail responses. Extend it to customize specific exception types while inheriting sensible defaults for the rest:
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
status, "Validation failed"
);
problem.setTitle("Invalid Request");
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
problem.setProperty("fieldErrors", fieldErrors);
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
The trap: ResponseEntityExceptionHandler declares @ExceptionHandler methods for roughly 15 Spring exceptions. If you also declare handlers for those same exceptions in another @ControllerAdvice, you get resolver ordering conflicts. The framework does not warn you. It picks one based on @Order. This is the source of most “my exception handler is not being called” bugs.
The Critical Gap: Filter Exceptions
Everything above applies exclusively to exceptions thrown inside handler methods (controllers). Exceptions thrown in servlet filters follow a completely different path.
When a Filter throws, the exception never reaches DispatcherServlet. The servlet container catches it and forwards the request to /error. Spring Boot’s BasicErrorController handles /error and produces a generic error response. Your @ExceptionHandler methods in @ControllerAdvice never see it.
This is where exceptions disappear.
The security filter chain from CH11 is the most common source. ExceptionTranslationFilter catches AccessDeniedException and AuthenticationException thrown by downstream security filters. It handles them internally: redirecting to login for authentication failures, returning 403 for access denied. Your @ControllerAdvice is not involved.
// BROKEN: This handler will never fire for security exceptions
// thrown in the filter chain. ExceptionTranslationFilter handles
// them before DispatcherServlet is reached.
@ControllerAdvice
public class SecurityExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ProblemDetail> handleAccessDenied(
AccessDeniedException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN,
"Access denied: " + ex.getMessage()
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(problem);
}
}
This handler compiles. It registers without error. It shows up in actuator mappings. It never executes for filter-chain security exceptions. The developer adds logging, adds breakpoints, redeploys. Nothing fires. The exception took a different highway.
// CORRECT: Configure security exception handling where it actually
// happens: in the security filter chain configuration.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
return http
.exceptionHandling(ex -> ex
.accessDeniedHandler((request, response, accessDeniedException) -> {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN,
"Access denied: " + accessDeniedException.getMessage()
);
problem.setTitle("Access Denied");
problem.setInstance(URI.create(request.getRequestURI()));
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
new ObjectMapper().writeValue(
response.getOutputStream(), problem
);
})
.authenticationEntryPoint((request, response, authException) -> {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED,
"Authentication required"
);
problem.setTitle("Unauthorized");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
new ObjectMapper().writeValue(
response.getOutputStream(), problem
);
})
)
.build();
}
The Exception Path Map
There are three distinct exception paths in a Spring Boot application. Mixing them up is the root cause of every “my error handler does not work” investigation.
Path 1: Controller exceptions. Thrown in @Controller or @RestController methods. Caught by DispatcherServlet. Resolved by HandlerExceptionResolver chain. Your @ExceptionHandler methods work here.
Path 2: Filter exceptions. Thrown in servlet Filter implementations. Never reach DispatcherServlet. Handled by the servlet container’s error page mechanism. In Spring Boot, this means forwarding to /error and BasicErrorController.
Path 3: Security exceptions. A subset of filter exceptions. ExceptionTranslationFilter intercepts AuthenticationException and AccessDeniedException from the security filter chain. Handled by AuthenticationEntryPoint and AccessDeniedHandler configured on HttpSecurity.
If your exception handler is not firing, determine which path the exception travels. Add a breakpoint in DispatcherServlet.processHandlerException(). If the breakpoint never hits, your exception is on Path 2 or 3. It never reached the dispatcher.
WebFlux: A Different World
Spring WebFlux does not have DispatcherServlet or HandlerExceptionResolver. It has DispatcherHandler and ErrorWebExceptionHandler.
DispatcherHandler catches exceptions from handler methods and wraps them in the reactive error signal. ErrorWebExceptionHandler is a functional interface that handles these signals at the web layer:
@Component
@Order(-2) // Before the default Spring Boot handler at @Order(-1)
public class TenantErrorWebExceptionHandler implements ErrorWebExceptionHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
if (ex instanceof TenantNotFoundException tnf) {
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
exchange.getResponse().getHeaders()
.setContentType(MediaType.APPLICATION_PROBLEM_JSON);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, tnf.getMessage()
);
problem.setProperty("tenantId", tnf.getTenantId());
byte[] bytes = serializeProblemDetail(problem);
DataBuffer buffer = exchange.getResponse()
.bufferFactory().wrap(bytes);
return exchange.getResponse().writeWith(Mono.just(buffer));
}
return Mono.error(ex); // Delegate to next handler
}
}
The @Order(-2) matters. Spring Boot registers DefaultErrorWebExceptionHandler at order -1. If your handler has a higher order number (lower priority), Spring Boot’s default handler catches the exception first and returns its generic error page.
WebFlux also supports @ControllerAdvice with @ExceptionHandler. The same resolution logic applies: local handlers first, then @ControllerAdvice classes by order. The difference is that WebFlux handlers can return reactive types: Mono<ResponseEntity<ProblemDetail>>.
What to Remember
The exception handling system is not one system. It is three systems bolted to the same application. Controller exceptions go through HandlerExceptionResolver. Filter exceptions go through the servlet container. Security exceptions go through ExceptionTranslationFilter. When an error handler does not fire, the exception is traveling the wrong road. No amount of @ExceptionHandler annotation will redirect it.