@ExceptionHandler

Marks a method that turns exceptions thrown by handler methods into an HTTP response, such as a problem detail.

Signature
@ExceptionHandler({NotFoundException.class, GoneException.class})
  • Added in Spring Boot Spring Framework 3.0

Parameters

ParameterTypeDescription
$valueoptionalClass<? extends Throwable>[]= {}Alias for exception.
$exceptionoptionalClass<? extends Throwable>[]= {}Exception types to handle. When empty, they are taken from the method's Throwable parameters.
$producesoptionalString[]= {}Media types this handler can produce. Lets you register different handlers for the same exception per Accept header.

Return value

n/aDeclares a method that handles the listed exception types (or, if none are listed, the types of its exception parameters) thrown by handler methods in the same controller, or in all controllers when declared in a @ControllerAdvice.

Errors

Two handlers for the same exception type (and media type) are ambiguous: in a @ControllerAdvice the application fails at startup; inside a controller the error appears on first use and the request ends in a 500.

Changelog

  • PHP Spring Framework 6.2Added the produces attribute for content negotiation between handlers.

An @ExceptionHandler method runs when a handler method throws. Instead of the exception reaching the servlet container as a 500, the handler decides the status and body. In a JSON API the natural return type is ProblemDetail, which Spring writes as application/problem+json (RFC 9457) with the status taken from the object.

Where it applies

Declared inside a controller, a handler only covers that controller. Declared in a @RestControllerAdvice class, it covers every controller the advice targets. Local handlers win over advice handlers. Within one class, Spring picks the handler whose exception type is closest to the thrown one, so a handler for SeatNotFoundException beats one for RuntimeException. It also looks at the cause chain, which helps when a framework wraps your exception.

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

Flexible signatures

The method can take the exception, WebRequest, HttpServletRequest, Locale and similar arguments, in any order. It can return ProblemDetail, ResponseEntity, any object (serialised as JSON with a status from @ResponseStatus) or void if it writes the response itself. When the exception types are listed in the annotation, the parameter can be a common supertype.

Since Spring Framework 6.2 the produces attribute lets two handlers for the same exception serve different media types, picked by the request's Accept header.

Mistakes people make

  • Copying the same handlers into every controller. Move shared ones to a @RestControllerAdvice; keep local handlers for exceptions only one controller throws.
  • A catch-all handler for Exception that returns the message. It leaks SQL, file paths and class names. It also swallows Spring's own exceptions (400s, 405s, 415s), @ResponseStatus exceptions and the AccessDeniedException thrown by method security, and turns them all into 500s.
  • Not logging. Once an exception is handled, nothing logs it for you. Log unexpected failures with the stack trace before returning a generic response.
  • Expecting it to catch filter errors. Exceptions thrown in servlet filters, including Spring Security's, happen before a handler method is chosen and never reach @ExceptionHandler.

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.PostMapping;
import org.springframework.web.bind.annotation.RestController;

record Seat(String id, boolean booked) {}

class SeatNotFoundException extends RuntimeException {
    SeatNotFoundException(String id) {
        super("Seat " + id + " does not exist");
    }
}

class SeatAlreadyBookedException extends RuntimeException {
    SeatAlreadyBookedException(String id) {
        super("Seat " + id + " is already booked");
    }
}

@RestController
public class SeatController {

    private final Map<String, Seat> seats = new ConcurrentHashMap<>(Map.of("A1", new Seat("A1", true)));

    @GetMapping("/api/seats/{id}")
    Seat seat(@PathVariable String id) {
        Seat seat = seats.get(id);
        if (seat == null) {
            throw new SeatNotFoundException(id);
        }
        return seat;
    }

    @PostMapping("/api/seats/{id}/booking")
    Seat book(@PathVariable String id) {
        if (seat(id).booked()) {
            throw new SeatAlreadyBookedException(id);
        }
        return seats.computeIfPresent(id, (k, s) -> new Seat(k, true));
    }

    // Applies only to exceptions thrown by this controller.
    @ExceptionHandler
    ProblemDetail notFound(SeatNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler
    ProblemDetail conflict(SeatAlreadyBookedException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    }
}

When to use it

  • Turning a domain not-found exception into a 404 problem detail
  • Mapping upstream timeouts to 503 without exposing the provider's name
  • Returning a CSV-shaped error to a spreadsheet integration

Frequently asked questions

Does a local @ExceptionHandler override one in @RestControllerAdvice?

Yes. Spring checks the controller's own handlers first and only falls back to advice classes when none match.

Can I catch exceptions from Spring Security filters?

No. Those happen before a handler method runs. Configure the security AuthenticationEntryPoint and AccessDeniedHandler instead.

More examples

One handler for several exception types

Listing the types in the annotation lets the parameter be their common supertype. Both a timeout and a connection failure become 503 with a message that hides the upstream details.

Example · java
import java.util.concurrent.TimeoutException;

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.RestController;
import org.springframework.web.client.ResourceAccessException;

@RestController
public class RatesController {

    @GetMapping("/api/rates")
    String rates() throws TimeoutException {
        throw new TimeoutException("rates provider did not answer in 2s");
    }

    // One handler for several types; the parameter is their common supertype.
    @ExceptionHandler({TimeoutException.class, ResourceAccessException.class})
    ProblemDetail upstreamDown(Exception ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
                "Exchange rates are temporarily unavailable. Try again shortly.");
        problem.setTitle("Upstream unavailable");
        return problem;
    }
}

Different error bodies per media type

With produces, Spring chooses the handler that matches the request's Accept header, so a CSV client gets a CSV error and a JSON client gets a problem detail. Declare the JSON handler as application/json: if it only produces application/problem+json, a request with Accept: application/json finds no handler and ends in a 500.

Example · java
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.GetMapping;
import org.springframework.web.bind.annotation.RestController;

class ReportNotReadyException extends RuntimeException {
}

@RestController
public class ReportDownloadController {

    @GetMapping(path = "/api/reports/latest", produces = {"application/json", "text/csv"})
    String latest() {
        throw new ReportNotReadyException();
    }

    // The produces attribute picks a handler by the client's Accept header.
    // Use application/json here: a client sending Accept: application/json does not
    // match a handler declared only for application/problem+json.
    @ExceptionHandler(exception = ReportNotReadyException.class, produces = "application/json")
    ProblemDetail notReadyJson() {
        return ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, "Report is still being generated.");
    }

    @ExceptionHandler(exception = ReportNotReadyException.class, produces = "text/csv")
    ResponseEntity<String> notReadyCsv() {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("error\nreport not ready\n");
    }
}

Testing a handler with MockMvcTester

The slice test sends a request, the controller throws, and the local handler turns it into a 410 problem detail. bodyJson() checks the detail field.

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

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
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 CouponExpiredException extends RuntimeException {
    CouponExpiredException(String code) {
        super("Coupon " + code + " has expired");
    }
}

@RestController
class CouponController {

    @GetMapping("/api/coupons/{code}")
    String coupon(@PathVariable String code) {
        throw new CouponExpiredException(code);
    }

    // A dedicated exception type: mapping IllegalArgumentException to 410 would
    // also catch unrelated library errors and echo their messages.
    @ExceptionHandler
    ProblemDetail expired(CouponExpiredException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.GONE, ex.getMessage());
    }
}

@WebMvcTest(CouponController.class)
class CouponControllerTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void expiredCouponIsGone() {
        assertThat(mvc.get().uri("/api/coupons/SUMMER"))
                .hasStatus(HttpStatus.GONE)
                .bodyJson()
                .extractingPath("$.detail").isEqualTo("Coupon SUMMER has expired");
    }
}

Discussion

  • Be the first to comment on this lesson.