skip to content

How does ResponseStatusExceptionResolver fit into the DispatcherServlet's HandlerExceptionResolver chain, and what determines whether @ResponseStatus vs an @ExceptionHandler wins for the same exception?

level: seniorimportance: should knowfreq 40%

answer

  1. Ordered chain; first non-null ModelAndView wins
  2. Order: ExceptionHandler → ResponseStatus → Default
  3. @ExceptionHandler beats @ResponseStatus
  4. AnnotatedElementUtils → meta-annotations work
  5. Reads raised type, not cause chain — wrapping hides it

basics

~20 s

DispatcherServlet has an ordered list of HandlerExceptionResolvers. ExceptionHandlerExceptionResolver (handles @ExceptionHandler) runs first, then ResponseStatusExceptionResolver (handles @ResponseStatus / ResponseStatusException), then DefaultHandlerExceptionResolver. The first that handles the exception wins, so a matching @ExceptionHandler takes precedence over @ResponseStatus.

solid answer

~40 s

When a handler throws, DispatcherServlet.processHandlerException iterates its ordered HandlerExceptionResolver beans and stops at the first that returns a non-null ModelAndView. The default chain (as configured by WebMvc) is: ExceptionHandlerExceptionResolver (dispatches to @ExceptionHandler / @ControllerAdvice methods), then ResponseStatusExceptionResolver (applies @ResponseStatus on the exception type and handles ResponseStatusException via getStatusCode/getReason), then DefaultHandlerExceptionResolver (maps built-in Spring MVC exceptions like MethodArgumentNotValidException to statuses). Because ExceptionHandlerExceptionResolver is earlier, a matching @ExceptionHandler wins and the @ResponseStatus never fires. ResponseStatusExceptionResolver reads the annotation via AnnotatedElementUtils, so meta-annotations work, but it looks at the raised exception type, not its cause chain — wrapping a @ResponseStatus exception in another exception hides the status.

code

java · 15 lines
java
@ResponseStatus(HttpStatus.CONFLICT) // 409, but...
class DuplicateException extends RuntimeException { }

@ControllerAdvice
class ApiErrors {
    // ExceptionHandlerExceptionResolver runs BEFORE ResponseStatusExceptionResolver,
    // so this wins: client gets 422, not 409.
    @ExceptionHandler(DuplicateException.class)
    ResponseEntity<String> onDup(DuplicateException e) {
        return ResponseEntity.unprocessableEntity().body("duplicate");
    }
}

// Cause-chain gotcha: annotation on the CAUSE is not detected
// throw new RuntimeException(new DuplicateException()); // → 500, not 409

go deeper

for a junior

Know Spring has resolvers that turn exceptions into responses.

for a middle

Name the three default resolvers and that the first match wins.

for a senior

Explain precedence (@ExceptionHandler > @ResponseStatus), AnnotatedElementUtils, and the cause-chain gotcha.

for a principal

Design a coherent chain: extend vs replace resolvers, central advice, ProblemDetail, and consistent ordering across modules.

## The chain When an exception escapes a handler method, `DispatcherServlet.processHandlerException()` walks its list of `HandlerExceptionResolver` beans in order and asks each to `resolveException(...)`. The **first** resolver returning a non-null `ModelAndView` wins; iteration stops. The default MVC configuration registers three, in this order: 1. **`ExceptionHandlerExceptionResolver`** — finds and invokes a matching `@ExceptionHandler` method (local to the controller or in a `@ControllerAdvice`). Most flexible: full control of body/status/headers. 2. **`ResponseStatusExceptionResolver`** — handles exceptions annotated with `@ResponseStatus` and instances of `ResponseStatusException`. Applies status via `response.sendError(...)`. 3. **`DefaultHandlerExceptionResolver`** — translates standard Spring MVC exceptions (e.g. `HttpRequestMethodNotSupportedException`→405, `MethodArgumentNotValidException`→400, `HttpMessageNotReadableException`→400) to statuses. ## Precedence consequence Because `ExceptionHandlerExceptionResolver` is first, **a matching `@ExceptionHandler` beats `@ResponseStatus`**. If you both annotate an exception with `@ResponseStatus(CONFLICT)` and write an `@ExceptionHandler(MyException.class)` that returns 422, the client gets **422** — the annotation is shadowed. This surprises people. Similarly, an `@ExceptionHandler` can intercept a `ResponseStatusException` and rewrite it. ## How ResponseStatusExceptionResolver resolves the status - For a `ResponseStatusException`: it calls `getStatusCode()`, `getReason()`, and applies `getHeaders()`. - For any other exception: it looks up `@ResponseStatus` using `AnnotatedElementUtils.findMergedAnnotation(exceptionType, ResponseStatus.class)`. Using `AnnotatedElementUtils` means **meta-annotations** work — you can create your own annotation meta-annotated with `@ResponseStatus`. ## Cause-chain gotcha The resolver inspects the **raised** exception type. It does not, by itself, walk the `getCause()` chain looking for a `@ResponseStatus` on a nested cause. So if a `@ResponseStatus`-annotated exception is caught and rethrown wrapped in a plain `RuntimeException`, the annotation is not found and you fall through to `DefaultHandlerExceptionResolver`/the container default (usually 500). Throw the annotated exception directly, or unwrap in an `@ExceptionHandler`. ## Ordering and customization All three implement `Ordered`. `ExceptionHandlerExceptionResolver` and `ResponseStatusExceptionResolver` default to `LOWEST_PRECEDENCE`, but `WebMvcConfigurationSupport` registers them in the fixed order above (adjusting orders). You can add your own resolver or reorder via `WebMvcConfigurer.extendHandlerExceptionResolvers`/`configureHandlerExceptionResolvers`. If you replace the list entirely, you may lose the defaults — usually you extend, not replace. ## Why it matters Understanding the chain explains: why a controller-advice handler silently overrides your annotated status; why wrapping breaks `@ResponseStatus`; and where to plug a global handler for a consistent error contract (a `@ControllerAdvice` extending `ResponseEntityExceptionHandler`, which in Spring 6 emits `ProblemDetail`).

  • You annotated an exception with @ResponseStatus(NOT_FOUND) but the client gets 500. What are two likely causes?
    (1) The annotated exception was wrapped in another exception before propagating, so ResponseStatusExceptionResolver can't see the annotation on the cause. (2) An @ExceptionHandler/@ControllerAdvice caught it and produced 500, or the exception was swallowed and rethrown as a generic one.
  • Can you create your own annotation that behaves like @ResponseStatus?
    Yes. Meta-annotate a custom annotation with @ResponseStatus; because the resolver uses AnnotatedElementUtils.findMergedAnnotation, the merged @ResponseStatus is discovered.
  • Where do @ExceptionHandler methods get dispatched from in the chain?
    From ExceptionHandlerExceptionResolver, the first resolver in the default chain, which is why they take precedence over @ResponseStatus.

saying these in an interview costs you the question

  • Thinking @ResponseStatus always wins over @ExceptionHandler
  • Assuming the resolver walks the getCause() chain to find @ResponseStatus
  • Believing there is only one HandlerExceptionResolver

context