Handling nested exceptions with a catch-all fallback in Spring-MVC

by Joris KuipersDecember 22, 2022

As our last blog for the year, here’s a quick tip to improve the exception handling in your Spring-MVC applications!

Cross-controller error handling using ControllerAdvice

In a Spring-MVC web application it’s common to use a class annotated with @(Rest)ControllerAdvice to handle uncaught exceptions via @ExceptionHandler methods. This way it’s easy to produce different HTTP responses based on the type of the exception.

Here’s an example based on my current project:

@RestControllerAdvice
public class MyControllerAdvice {

  @ExceptionHandler
  ResponseEntity<WebErrorModel> handle(SocketTimeoutException e) {
    logger.warn("Socket timeout handling request", e);
    return ResponseEntity
      .status(HttpStatus.GATEWAY_TIMEOUT)
      .body(new WebErrorModel()
        .code(ErrorCode.TIMEOUT)
        .message("Timeout calling backend"));
   }
}

Whenever a service used by a controller throws an exception indicating a socket timeout occurred in a downstream call, we want to return a 504 response with some error model that we’ve defined in our OpenAPI spec and have generated code for. 

This advice is defined in a shared library used by all public services in my multi-module mono-repo and configured using Spring Boot autoconfiguration.

Unwrapping your presents exceptions

The thing is, if our HTTP client code encounters a socket timeout, it doesn’t actually result in a SocketTimeoutException that’s propagated to the controller. That exception ends up being wrapped in other exceptions, so it’s just some nested cause.

Fortunately, Spring-MVC has support for not only matching the uncaught exceptions directly against your exception handling methods, but also the contained nested exceptions (since 5.3). That means that if there’s no exception handler for the top-level exception but there is one for its nested exception, it will be used automatically.

Spring will also make sure to match the most specific exception handler based on the exception type, which is very convenient. 

This behavior does cause an issue, though, if you want to introduce some catch-all fallback handler combined with the nested exception support.

All I want for Christmas is to have my cake and eat it too

To ensure that our services always have a proper error response, we have a catch-all exception handler method like this:

/**
 * Spring tries {@code @ExceptionHandler} methods first,
 * before it considers {@link ResponseStatus} annotations
 * on exceptions: to prevent this method from handling
 * those exceptions, check ourselves and then rethrow those.
 */
@ExceptionHandler
ResponseEntity<WebErrorModel> handleUncaughtException(WebRequest request, RuntimeException e)
{
  if (AnnotatedElementUtils.findMergedAnnotation(e.getClass(), ResponseStatus.class) != null) throw e;
  logger.warn("Handling uncaught controller exception for {}", request, e);
  return ResponseEntity.internalServerError()
    .body(new WebErrorModel()
      .message(e.getMessage())
      .code(ErrorCode.TECHNICAL));
}

As you can see, introducing such a method will conflict with Spring-MVC’s support for annotating custom exception types with @ResponseStatus, so if we detect that we rethrow the exception.

Unfortunately it causes another issue: whenever there’s an exception now, it will match our catch-all handler, thus preventing any nested exceptions from being matched.
This is not what we want: we want to try to handle specific exceptions including their nested exceptions first, and only have our catch-all method to be used if that didn’t result in a match.

After inspecting the Spring source code, it becomes clear though that there’s an easy option to achieve this effect.

Let all things be done decently and in order

The trick is to define our catch-all exception handler in a dedicated controller advice, and to ensure that that Spring bean will have a lower precedence than the one for our controller advice that has the regular, specifically-typed exception handler methods.

With that setup, Spring will first try to use our regular advice, including a match based on nested exceptions. Only after it has exhausted all options there will it consider our catch-all advice, thus leading to the behavior that we’re after.

This is what that looks like.
Our normal controller advice receives an order of 1:

@RestControllerAdvice
@Order(1)
public class MyControllerAdvice {
  // regular exception handlers go here
}

And then our advice containing the catch-all handler uses the default order giving it the lowest precedence:

/**
 * Contains a catch-all to handle exceptions that aren't
 * otherwise handled already. Uses lowest precedence order
 * so that the {@link MyControllerAdvice} is tried first,
 * also for nested exceptions, before this one is even considered.
 */
@RestControllerAdvice
@Order
public class MyControllerCatchAllAdvice {

  private Logger logger = LoggerFactory.getLogger(getClass());

  /**
   * Spring tries {@code @ExceptionHandler} methods first,
   * before it considers {@link ResponseStatus} annotations
   * on exceptions: to prevent this method from handling
   * those exceptions, check ourselves and then rethrow those.
   */
  @ExceptionHandler
  ResponseEntity<WebErrorModel> handleUncaughtException(WebRequest request, RuntimeException e)
  {
    if (AnnotatedElementUtils.findMergedAnnotation(e.getClass(), ResponseStatus.class) != null) throw e;
    logger.warn("Handling uncaught controller exception for {}", request, e);
    return ResponseEntity.internalServerError()
      .body(new WebErrorModel()
        .message(e.getMessage())
        .code(ErrorCode.TECHNICAL));
   }
}

And that’s it!

Make sure leave a comment if you found this useful. For more Spring Boot tips & tricks, also check out my Spring I/O presentation.

Happy holidays!