Skip to main content
spring internals

The HandlerExceptionResolver Chain

7 min read Chapter 50 of 78

The Exception Leaves the Controller

A tenant administrator calls POST /api/tenants/{tenantId}/orders with an invalid payload. The OrderController.createOrder() method validates the request, finds the tenant’s subscription has expired, and throws:

@RestController
@RequestMapping("/api/tenants/{tenantId}/orders")
public class OrderController {

    private final OrderService orderService;
    private final TenantContext tenantContext;

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @PathVariable String tenantId,
            @Valid @RequestBody CreateOrderRequest request) {
        Tenant tenant = tenantContext.resolve(tenantId);
        if (tenant.getSubscription().isExpired()) {
            throw new SubscriptionExpiredException(tenantId,
                tenant.getSubscription().getExpiryDate());
        }
        Order order = orderService.create(tenant, request);
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(OrderResponse.from(order));
    }
}

The SubscriptionExpiredException leaves the controller method. DispatcherServlet catches it. Here is the relevant code from DispatcherServlet.doDispatch():

try {
    // Handler adapter invokes the controller method
    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
} catch (Exception ex) {
    dispatchException = ex;
} catch (Throwable err) {
    dispatchException = new ServletException(
        "Handler dispatch failed: " + err, err);
}
// Later in the same method:
processDispatchResult(processedRequest, response,
    mappedHandler, mv, dispatchException);

The exception is stored, not rethrown. processDispatchResult() calls processHandlerException() when dispatchException is non-null.

The Resolver Iteration

processHandlerException() iterates this.handlerExceptionResolvers, an ordered list populated at startup:

protected ModelAndView processHandlerException(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler,
        Exception ex) throws Exception {

    ModelAndView exMv = null;
    if (this.handlerExceptionResolvers != null) {
        for (HandlerExceptionResolver resolver :
                this.handlerExceptionResolvers) {
            exMv = resolver.resolveException(request, response, handler, ex);
            if (exMv != null) {
                break;
            }
        }
    }
    if (exMv != null) {
        // Use the ModelAndView returned by the resolver
        if (exMv.isEmpty()) {
            request.setAttribute(
                EXCEPTION_ATTRIBUTE, ex);
            return null;
        }
        return exMv;
    }
    // No resolver handled it: rethrow
    throw ex;
}

First-match semantics. The moment a resolver returns a non-null ModelAndView, the loop breaks. Remaining resolvers are skipped. If all resolvers return null, the exception propagates to the servlet container.

The exMv.isEmpty() check is subtle. When your @ExceptionHandler writes directly to the response (through ResponseEntity), the framework returns an empty ModelAndView. Empty means “the response is already committed, do not try to render a view.” The method returns null to signal that dispatch is complete.

ExceptionHandlerExceptionResolver: Finding @ExceptionHandler Methods

The first resolver in the chain is ExceptionHandlerExceptionResolver. This is the one that makes @ExceptionHandler work. Its resolution algorithm:

  1. Get the controller class that threw the exception (from the handler parameter).
  2. Search that controller class for @ExceptionHandler methods whose declared exception type matches or is a parent of the thrown exception.
  3. If found, invoke it. Return the result as ModelAndView.
  4. If not found locally, search all registered @ControllerAdvice beans.
  5. Iterate @ControllerAdvice beans in @Order sequence.
  6. For each, check if the advice applies to this controller (based on basePackages, annotations, or assignableTypes).
  7. If applicable, search for a matching @ExceptionHandler in that advice.
  8. First match wins.

The exception type matching uses class hierarchy. For SubscriptionExpiredException extends BusinessException extends RuntimeException, a handler declared for BusinessException matches. But if another @ControllerAdvice declares a handler for RuntimeException, and it has higher priority (lower @Order value), that handler wins instead.

@ControllerAdvice
@Order(10)
public class BusinessExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ProblemDetail> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()
        );
        problem.setTitle("Business Rule Violation");
        problem.setProperty("errorCode", ex.getErrorCode());
        problem.setInstance(URI.create(request.getRequestURI()));
        return ResponseEntity
            .status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(problem);
    }
}

This handler catches SubscriptionExpiredException because it extends BusinessException. The @Order(10) ensures it runs after more specific handlers with lower order values.

ResponseStatusExceptionResolver: Annotation-Driven Status Codes

If ExceptionHandlerExceptionResolver returns null (no matching handler found), ResponseStatusExceptionResolver takes over. It checks whether the thrown exception class carries @ResponseStatus:

@ResponseStatus(HttpStatus.CONFLICT)
public class DuplicateOrderException extends RuntimeException {
    public DuplicateOrderException(String orderId) {
        super("Order already exists: " + orderId);
    }
}

When this exception is thrown, ResponseStatusExceptionResolver reads the annotation, calls response.sendError(409, "Order already exists: ORD-123"), and returns a non-empty ModelAndView. The servlet container then forwards to /error.

The limitation: response.sendError() triggers the servlet container’s error page handling. For REST APIs, this means the response body is whatever BasicErrorController produces, not the structured ProblemDetail you want. This is why @ResponseStatus on exception classes is rarely the right choice for APIs. Use @ExceptionHandler with explicit ProblemDetail construction instead.

ResponseStatusExceptionResolver also handles ResponseStatusException, a runtime exception that carries its status code as a constructor argument. Spring’s built-in exceptions like ResponseStatusException and its subtypes bypass the annotation check and use the embedded status directly.

DefaultHandlerExceptionResolver: Spring’s Internal Mapping

The last resolver handles Spring’s own exceptions. When a client sends DELETE to an endpoint that only accepts GET and POST, Spring throws HttpRequestMethodNotSupportedException. DefaultHandlerExceptionResolver catches it and returns a 405 response with an Allow header listing the valid methods.

The full mapping includes:

ExceptionStatus Code
HttpRequestMethodNotSupportedException405
HttpMediaTypeNotSupportedException415
HttpMediaTypeNotAcceptableException406
MissingPathVariableException500
MissingServletRequestParameterException400
MissingServletRequestPartException400
ServletRequestBindingException400
MethodArgumentNotValidException400
HandlerMethodValidationException400
NoHandlerFoundException404
TypeMismatchException400

This resolver also calls response.sendError(), which means the same limitation applies: the response body comes from the error page mechanism unless you have spring.mvc.problemdetail.enabled=true.

The Ordering Failure

Here is the bug that consumes debugging hours in multi-team SaaS codebases:

// BROKEN: Two @ControllerAdvice classes both handle Exception.class.
// Resolution order depends on classpath scanning order, which
// depends on package names, which nobody controls deliberately.

@ControllerAdvice
public class TeamAlphaExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, String>> handleAll(Exception ex) {
        // Team Alpha's format: {"error": "message"}
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(Map.of("error", ex.getMessage()));
    }
}

@ControllerAdvice
public class TeamBetaExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
        // Team Beta's format: ProblemDetail (RFC 9457)
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(problem);
    }
}

Both handlers match every exception. The framework picks whichever @ControllerAdvice it finds first during component scanning. This ordering is not deterministic across environments. The response format flips between deployments. API clients break intermittently.

// CORRECT: Explicit ordering and specific exception types.
// The catch-all handler runs last and uses the team's agreed format.

@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class BusinessExceptionHandler {

    @ExceptionHandler(SubscriptionExpiredException.class)
    public ResponseEntity<ProblemDetail> handleSubscriptionExpired(
            SubscriptionExpiredException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.PAYMENT_REQUIRED,
            "Subscription expired on " + ex.getExpiryDate()
        );
        problem.setTitle("Subscription Expired");
        problem.setProperty("tenantId", ex.getTenantId());
        problem.setProperty("expiryDate", ex.getExpiryDate().toString());
        return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
            .body(problem);
    }

    @ExceptionHandler(TenantNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleTenantNotFound(
            TenantNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage()
        );
        problem.setTitle("Tenant Not Found");
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }
}

@ControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class FallbackExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleUnexpected(
            Exception ex, HttpServletRequest request) {
        // Log the full stack trace. Return a safe message.
        log.error("Unhandled exception at {}", request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred"
        );
        problem.setTitle("Internal Server Error");
        problem.setInstance(URI.create(request.getRequestURI()));
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(problem);
    }
}

@Order(Ordered.HIGHEST_PRECEDENCE) on the business handler ensures specific exceptions are caught first. @Order(Ordered.LOWEST_PRECEDENCE) on the fallback ensures it only handles what nothing else caught. The response format is consistent. The ordering is explicit.

The Null Return

When all three resolvers return null, processHandlerException() rethrows the original exception. The servlet container catches it. In a default Spring Boot application, this triggers a forward to /error, where BasicErrorController generates a response.

This path produces the white-label error page in browsers and a JSON {"timestamp":..., "status":500, "error":"Internal Server Error"} for API clients. If you see this response in production, no resolver handled the exception. Either your @ExceptionHandler does not match the exception type, your @ControllerAdvice is not scoped to the throwing controller, or the exception originated outside the controller layer entirely.

Put a breakpoint in DispatcherServlet.processHandlerException(). If it fires and all resolvers return null, your handler configuration is wrong. If it never fires, the exception is not coming from a controller. The next section covers where those exceptions go.