skip to content

How do you control the HTTP status code and response body from a local @ExceptionHandler? Compare @ResponseStatus and ResponseEntity.

level: seniorimportance: should knowfreq 40%

answer

  1. @ResponseStatus = fixed static code
  2. ResponseEntity = runtime status+headers+body
  3. ResponseEntity status overrides @ResponseStatus
  4. plain body + neither = 200 bug
  5. @ResponseStatus(reason) -> sendError drops body

basics

~10 s

Either annotate the handler with @ResponseStatus(HttpStatus.NOT_FOUND) and return the body object, or return a ResponseEntity that sets status, headers and body directly. ResponseEntity gives per-response control; @ResponseStatus is a fixed static status.

solid answer

~40 s

Two mechanisms. @ResponseStatus on the handler method sets a fixed status; the returned value (with @ResponseBody, implicit in @RestController) becomes the body. It's declarative and clean when the status never varies. ResponseEntity is programmatic: you build status, headers, and body at runtime, so you can choose 404 vs 409 based on the exception, add headers like Retry-After, or return no body. If you return a ResponseEntity, any @ResponseStatus on the method is ignored — the ResponseEntity's status wins. For a plain body with neither, the status defaults to 200, which is a common bug. Note also that a local handler takes precedence over @ControllerAdvice, so a controller-local handler can override the global status/body policy for its own endpoints.

code

java · 15 lines
java
// Fixed status: declarative
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ApiError missing(OrderNotFoundException ex) {
    return new ApiError("NOT_FOUND", ex.getMessage());
}

// Variable status + headers: programmatic
@ExceptionHandler(BusinessException.class)
ResponseEntity<ApiError> business(BusinessException ex) {
    HttpStatus status = ex.isConflict() ? HttpStatus.CONFLICT : HttpStatus.BAD_REQUEST;
    return ResponseEntity.status(status)
            .header("X-Error-Code", ex.getCode())
            .body(new ApiError(ex.getCode(), ex.getMessage()));
}

go deeper

for a junior

Know the two ways: @ResponseStatus for a fixed code, or return a ResponseEntity.

for a middle

Explain when to pick each and the default-200 pitfall.

for a senior

Cover ResponseEntity overriding @ResponseStatus and the reason/sendError body-loss gotcha.

for a principal

Discuss consistent error-contract design and how local handlers override global policy per controller.

## Goal An exception handler must produce two things for a REST client: an **HTTP status code** and a **response body**. Spring gives you two complementary tools. ## Option 1 — `@ResponseStatus` Put `@ResponseStatus(HttpStatus.NOT_FOUND)` on the `@ExceptionHandler` method. Spring sets that status on the response, and the method's return value (serialized via `@ResponseBody`, which is implicit in a `@RestController`) becomes the body. ```java @ExceptionHandler(OrderNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) ApiError handle(OrderNotFoundException ex) { return new ApiError("NOT_FOUND", ex.getMessage()); } ``` - **Pros:** declarative, minimal code, self-documenting. - **Cons:** the status is **static** — you can't vary it at runtime, and you can't set arbitrary headers. ## Option 2 — `ResponseEntity` Return a fully-built `ResponseEntity`: ```java @ExceptionHandler(BusinessException.class) ResponseEntity<ApiError> handle(BusinessException ex) { HttpStatus status = ex.isConflict() ? HttpStatus.CONFLICT : HttpStatus.BAD_REQUEST; return ResponseEntity.status(status) .header("X-Error-Code", ex.getCode()) .body(new ApiError(ex.getCode(), ex.getMessage())); } ``` - **Pros:** full runtime control — variable status, custom headers, optional/empty body. - **Cons:** more verbose. ## Interaction between the two If a handler returns a `ResponseEntity`, **the `ResponseEntity`'s status wins** and any `@ResponseStatus` on the method is effectively ignored for the status. Don't rely on mixing them. ## The 200 gotcha If you return a **plain body object** and provide **neither** `@ResponseStatus` **nor** `ResponseEntity`, the response status defaults to **200 OK** — an error rendered as success. Always pick one of the two mechanisms. ## `@ExceptionHandler` + `@ResponseStatus` reason `@ResponseStatus` also has a `reason` attribute, but if `reason` is set Spring calls `HttpServletResponse.sendError(...)`, which triggers the container error page and **discards your body**. For REST APIs, prefer leaving `reason` empty (or use `ResponseEntity`) so your JSON body is preserved. ## Precedence over global advice Because `ExceptionHandlerExceptionResolver` checks the raising **controller's own** handlers **before** `@ControllerAdvice`, a local handler can deliberately override the global status/body policy for that one controller. (Global handling itself is the `@ControllerAdvice` topic.) ## Views instead of JSON For server-rendered apps a handler may instead return a `String` view name or `ModelAndView`; combine with `@ResponseStatus` to still set the right code on the error page. ## Gotchas summary - Plain body + nothing ⇒ 200. - `ResponseEntity` status overrides `@ResponseStatus`. - `@ResponseStatus(reason=...)` uses `sendError` and drops your body. - Use `@ResponseStatus` for fixed codes, `ResponseEntity` when the status/headers vary.

  • If a handler is annotated @ResponseStatus(CONFLICT) but returns a ResponseEntity with status BAD_REQUEST, what does the client get?
    400 BAD_REQUEST. When a ResponseEntity is returned, its status controls the response and the @ResponseStatus annotation is ignored for the status code.
  • Why might your custom JSON body vanish when you set @ResponseStatus(reason = "...")?
    A non-empty reason makes Spring call HttpServletResponse.sendError(), which delegates to the container's error handling and discards the returned body. Omit reason (or use ResponseEntity) to keep the body.

saying these in an interview costs you the question

  • Assuming a returned error object automatically produces a non-200 status
  • Believing @ResponseStatus overrides a returned ResponseEntity's status
  • Not knowing @ResponseStatus(reason) triggers sendError and drops the body
  • Thinking you cannot vary the status at runtime (ResponseEntity lets you)

context