Spring Boot's @ControllerAdvice Can't Catch Exceptions Thrown in Filters and Interceptors

In Spring Boot we commonly use @RestControllerAdvice and @ControllerAdvice to catch exceptions globally and handle them gracefully — but it can't really catch everything. A case I ran into: a custom filter extending OncePerRequestFilter validates the token, and when the token expires it throws an ExpiredJwtException that never gets caught.

In Spring Boot we commonly use @RestControllerAdvice and @ControllerAdvice to catch exceptions globally and handle them gracefully — but it can’t really catch everything. A case I ran into: a custom filter extending OncePerRequestFilter validates the token, and when the token expires it throws an ExpiredJwtException that never gets caught. The problem is precisely that the exception is thrown in a Filter. Let’s look at how Spring Boot orders filters, interceptors and controllers.

Execution order of Filter, Interceptor and ControllerAdvice

When a request comes in, it flows through in this order:

  1. Filter: a Java Web technology that processes the request before or after it reaches the Servlet. Filters run first.
  2. Interceptor: a Spring MVC technology that processes the request before or after it reaches the Controller. Interceptors run after filters, but before the request reaches the Controller.
  3. ControllerAdvice: a Spring MVC annotation used to define a global exception handler, data binder and model handler.

In short: Filter → Interceptor → ControllerAdvice.

Why ControllerAdvice can’t catch filter/interceptor exceptions

Given the order above: if an exception is thrown in a filter or interceptor, ControllerAdvice can’t catch it, because execution stops before ControllerAdvice is ever reached. Exceptions thrown at the Controller layer or below (Service, DAO) are catchable; anything thrown before the Controller layer is not.

Catching Filter exceptions with ControllerAdvice

Now that we understand the mechanics, the trick for catching filter exceptions with ControllerAdvice is to let execution continue downstream after the exception instead of throwing it inside the filter. Based on the flow, we do this:

  1. In the Filter, try-catch the exception, stash the exception object in a request attribute, and explicitly forward to a designated controller for the Dispatcher to handle.
  2. Create a controller that receives the exception: pull the exception object out of the attribute and re-throw it at the Controller layer.

Code example. In the Filter:

public class DemoFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try{
            // 假设在这里会抛出一个 ExpiredJwtException 异常
            filterChain.doFilter(servletRequest, servletResponse);
        }catch (ExpiredJwtException e){
            // 将异常对象设置到 Attribute 属性中
            servletRequest.setAttribute("filter.error", e);
            // 告诉 Dispatcher 处理的 Controller
            servletRequest.getRequestDispatcher("/error/exthrow").forward(servletRequest, servletResponse);
        }
    }
}

(Assume an ExpiredJwtException is thrown here; stash the exception object in a request attribute; tell the Dispatcher which controller to forward to.)

New controller to receive the exception:

@Controller
public class ExceptionThrowController {
    @RequestMapping("/error/exthrow")
    public void returnThrow(HttpServletRequest request) throws Exception {
        // 从 Attribute 属性中取出异常对象,重新在 Controller 层抛出
        throw ((Exception) request.getAttribute("filter.error"));
    }
}

Now ControllerAdvice can catch it:

@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value = ExpiredJwtException.class)
    public ApiResult<?> expiredJwtExceptionHandler(ExpiredJwtException e){
        return ApiResult.builder()
                .code(HttpStatus.UNAUTHORIZED)
                .message("JWT expired")
                .build();
    }
}

Understand the mechanics and your code will flow. Or do you have a better solution? Feel free to share it in the comments.