@ExceptionHandler

Turn a specific exception into a deliberate HTTP response with an @ExceptionHandler method, and learn how Spring picks which handler runs.

Syntax@ExceptionHandler // type taken from the parameter ReturnType handle(SomeException ex) { ... } @ExceptionHandler({A.class, B.class}) // common supertype as parameter @ResponseStatus(HttpStatus.BAD_REQUEST) Body handle(RuntimeException ex) { ... }

An @ExceptionHandler method is a controller method for failures. When a handler method throws, Spring MVC looks for an exception handler that matches the exception, calls it, and writes whatever it returns as the response. The request never reaches Boot's /error endpoint, so you control the status, headers and body.

Declaring one

Put the method in the controller that throws. The exception type comes from the annotation's value or, when the annotation is empty, from the method's exception parameter:

@ExceptionHandler
ProblemDetail handleNotFound(OrderNotFoundException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}

The method can also take the HttpServletRequest, a WebRequest, a Locale or the HandlerMethod that failed. It can return a ProblemDetail, a ResponseEntity when you need headers, or any object plus @ResponseStatus. In a @RestController the return value is written as the body, just like a normal endpoint.

How Spring chooses

When several handlers could apply, the one declared for the closest type in the exception's class hierarchy wins: a handler for OrderNotFoundException beats one for RuntimeException. Spring also matches against the exception's causes, so a handler for OrderNotFoundException still runs when the exception arrives wrapped in another one. Two methods in one class mapped to the same exception type are rejected with an "Ambiguous @ExceptionHandler method" error, not a tie broken at random. For an advice bean that happens at startup; for a controller's own handlers Spring builds the lookup lazily, so the error only appears the first time that controller throws.

When a local handler is the right tool

A handler inside a controller applies only to exceptions thrown by that controller. That is ideal for an error that only one endpoint can produce, such as a report endpoint that rejects a bad date range. For anything thrown by shared services, such as "not found" or "conflict", a local handler means copying the same method into every controller, and the copies drift apart. Move those to a @RestControllerAdvice, covered next. Local handlers are tried before advice, so a controller can still override the global answer for its own routes.

Mistakes to avoid

  • Returning ex.getMessage() for exceptions you did not create. Library messages can include SQL, hostnames or user data.
  • Returning a plain String without @ResponseStatus. The response is then a 200 with an error text, and clients treat it as success.
  • Catching an exception inside the endpoint just to build an error response by hand. Throw, and let one handler build every response of that kind.

Example

Example · java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

class OrderNotFoundException extends RuntimeException {
    private final long orderId;

    OrderNotFoundException(long orderId) {
        super("Order " + orderId + " not found");
        this.orderId = orderId;
    }

    long orderId() {
        return orderId;
    }
}

record OrderResponse(long id, String status) {}

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final Map<Long, OrderResponse> orders = new ConcurrentHashMap<>();

    @GetMapping("/{id}")
    OrderResponse find(@PathVariable long id) {
        OrderResponse order = orders.get(id);
        if (order == null) {
            throw new OrderNotFoundException(id);
        }
        return order;
    }

    // The parameter type tells Spring which exception this method handles.
    @ExceptionHandler
    ProblemDetail handleNotFound(OrderNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setProperty("orderId", ex.orderId());
        return problem;
    }
}

When to use it

  • Return 404 with the missing order id when an order lookup fails in one controller.
  • Answer an export request with 503 and a Retry-After header while another export runs.
  • Give a sales report endpoint its own 400 message for unparseable or reversed date ranges without affecting other endpoints.

More examples

Setting a header from a handler

A 503 is more useful with <code>Retry-After</code>, and only <code>ResponseEntity</code> can set headers from a handler. The exception carries the data the handler needs, so the endpoint stays a single line that throws. Clients and proxies that honour <code>Retry-After</code> back off instead of hammering the server.

Example · java
import java.time.Duration;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

class ExportBusyException extends RuntimeException {
    private final Duration retryAfter;

    ExportBusyException(Duration retryAfter) {
        super("An export is already running");
        this.retryAfter = retryAfter;
    }

    Duration retryAfter() {
        return retryAfter;
    }
}

@RestController
public class ExportController {

    @PostMapping("/api/exports")
    void startExport() {
        throw new ExportBusyException(Duration.ofSeconds(30));
    }

    // ResponseEntity lets the handler set headers as well as the body.
    @ExceptionHandler
    ResponseEntity<ProblemDetail> busy(ExportBusyException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, ex.getMessage());
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.retryAfter().toSeconds()))
                .body(problem);
    }
}

One handler for several exception types

Listing classes in the annotation lets one method cover failures that share an answer. The parameter must be a type both exceptions can be assigned to, here <code>RuntimeException</code>. The response deliberately does not echo the parser's message: it tells the caller what a valid range looks like instead. Note the handler only covers this controller, which is right for an error no other endpoint can produce.

Example · java
import java.time.LocalDate;
import java.time.format.DateTimeParseException;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

class InvalidRangeException extends RuntimeException {
    InvalidRangeException(LocalDate from, LocalDate to) {
        super("Range end " + to + " is before start " + from);
    }
}

record ReportError(String code, String message) {}

record SalesReport(LocalDate from, LocalDate to, long totalPence) {}

@RestController
public class ReportController {

    @GetMapping("/api/reports/sales")
    SalesReport sales(@RequestParam String from, @RequestParam String to) {
        LocalDate start = LocalDate.parse(from);
        LocalDate end = LocalDate.parse(to);
        if (end.isBefore(start)) {
            throw new InvalidRangeException(start, end);
        }
        return new SalesReport(start, end, 0);
    }

    // Two unrelated exception types, one handler: the parameter is their common supertype.
    @ExceptionHandler({DateTimeParseException.class, InvalidRangeException.class})
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    ReportError badRange(RuntimeException ex) {
        return new ReportError("BAD_REPORT_RANGE",
                "Use ISO dates such as 2026-10-01, with 'to' on or after 'from'.");
    }
}

Testing a handler with MockMvcTester

<code>MockMvcTester.of(controller)</code> builds a standalone MockMvc without starting Spring, and it still applies the controller's own <code>@ExceptionHandler</code> methods. Asserting on the status, the <code>application/problem+json</code> content type and one JSON field catches the common regression where a handler is deleted and the endpoint quietly starts returning a 500.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

class TicketArchivedException extends RuntimeException {
    TicketArchivedException(long id) {
        super("Ticket " + id + " is archived");
    }
}

// Normally in src/main/java; repeated here so the test stands alone.
@RestController
class TicketController {

    @GetMapping("/api/tickets/{id}")
    String find(@PathVariable long id) {
        throw new TicketArchivedException(id);
    }

    @ExceptionHandler
    ProblemDetail archived(TicketArchivedException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.GONE, ex.getMessage());
    }
}

class TicketControllerTest {

    // Standalone setup still honours @ExceptionHandler methods on the controller.
    private final MockMvcTester mvc = MockMvcTester.of(new TicketController());

    @Test
    void archivedTicketIsGone() {
        assertThat(mvc.get().uri("/api/tickets/7"))
                .hasStatus(HttpStatus.GONE)
                .hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)
                .bodyJson()
                .extractingPath("$.detail")
                .isEqualTo("Ticket 7 is archived");
    }
}

Discussion

  • Be the first to comment on this lesson.