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:
- Filter: a Java Web technology that processes the request before or after it reaches the Servlet. Filters run first.
- 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.
- 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:
- 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.
- 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.
