Spring BootException Handling in APIs
Spring Boot

Exception Handling in APIs

Proper exception handling shapes how errors are communicated to API consumers. Spring Boot provides several layers — @ExceptionHandler on a controller, @RestControllerAdvice globally, ResponseStatusException for quick inline errors, and ProblemDetail for RFC 9457 compliance. This entry covers all four approaches, when to use each, and how to build a consistent error response contract.

Controller-Level @ExceptionHandler

@ExceptionHandler methods inside a @RestController catch exceptions thrown by that controller only. They are the right tool when an exception is specific to a single resource and the handling logic does not need to be shared. For exceptions that cross multiple controllers, use @RestControllerAdvice instead.
Java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @PostMapping
    public ResponseEntity<UserResponse> create(
            @RequestBody @Valid CreateUserRequest request,
            UriComponentsBuilder uriBuilder) {
        UserResponse created = userService.create(request);
        URI location = uriBuilder
            .path("/api/v1/users/{id}")
            .buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
    }

    // ── Handles exceptions thrown only by this controller ─────────────
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(
            UserNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of(404, "Not Found", ex.getMessage()));
    }

    @ExceptionHandler(DuplicateEmailException.class)
    public ResponseEntity<ErrorResponse> handleDuplicateEmail(
            DuplicateEmailException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ErrorResponse.of(409, "Conflict", ex.getMessage()));
    }

    // ── Catch-all for this controller only ────────────────────────────
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Internal Server Error",
                "An unexpected error occurred"));
    }
}

ResponseStatusException

ResponseStatusException is a runtime exception that carries an HTTP status code. Throw it inline inside a controller or service for simple, one-off error cases that do not warrant a dedicated exception class. It avoids boilerplate but provides less structure than a dedicated exception hierarchy for large APIs.
Java
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductRepository productRepo;
    private final UserRepository    userRepo;

    // ── Inline 404 — no dedicated exception class needed ─────────────
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> findById(@PathVariable Long id) {
        return productRepo.findById(id)
            .map(ProductResponse::from)
            .map(ResponseEntity::ok)
            .orElseThrow(() -> new ResponseStatusException(
                HttpStatus.NOT_FOUND,
                "Product not found with id: " + id));
    }

    // ── 403 Forbidden ─────────────────────────────────────────────────
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(
            @PathVariable Long id,
            @AuthenticationPrincipal UserDetails principal) {

        Product product = productRepo.findById(id)
            .orElseThrow(() -> new ResponseStatusException(
                HttpStatus.NOT_FOUND, "Product not found: " + id));

        if (!product.getOwnerId().equals(getUserId(principal))) {
            throw new ResponseStatusException(
                HttpStatus.FORBIDDEN,
                "You are not allowed to delete this product");
        }

        productRepo.delete(product);
        return ResponseEntity.noContent().build();
    }

    // ── 422 Unprocessable Entity ──────────────────────────────────────
    @PostMapping("/{id}/publish")
    public ResponseEntity<ProductResponse> publish(@PathVariable Long id) {
        Product product = productRepo.findById(id)
            .orElseThrow(() -> new ResponseStatusException(
                HttpStatus.NOT_FOUND, "Product not found: " + id));

        if (product.getStatus() != ProductStatus.DRAFT) {
            throw new ResponseStatusException(
                HttpStatus.UNPROCESSABLE_ENTITY,
                "Only DRAFT products can be published");
        }

        return ResponseEntity.ok(ProductResponse.from(
            productRepo.save(product.publish())));
    }
}

RFC 9457 ProblemDetail

ProblemDetail is Spring Framework 6's built-in implementation of RFC 9457 (Problem Details for HTTP APIs). It produces a standardised application/problem+json response body with fields type, title, status, detail, and instance. Enable it globally with spring.mvc.problemdetails.enabled=true or return ProblemDetail directly from exception handlers.
yaml
# application.yml — enable ProblemDetail for built-in Spring exceptions
spring:
  mvc:
    problemdetails:
      enabled: true

// ── Returning ProblemDetail from an exception handler ─────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleUserNotFound(
            UserNotFoundException ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail
            .forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());

        problem.setTitle("User Not Found");
        problem.setType(URI.create("https://api.myapp.com/errors/user-not-found"));
        problem.setInstance(URI.create(request.getRequestURI()));

        // Custom extension fields
        problem.setProperty("errorCode",  "USER_001");
        problem.setProperty("timestamp",  Instant.now());

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidation(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail
            .forStatusAndDetail(HttpStatus.BAD_REQUEST,
                "One or more fields failed validation");

        problem.setTitle("Validation Failed");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("fieldErrors",
            ex.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(
                    FieldError::getField,
                    fe -> fe.getDefaultMessage() != null
                            ? fe.getDefaultMessage() : "Invalid",
                    (a, b) -> a)));

        return ResponseEntity.badRequest().body(problem);
    }
}

// ── Example problem+json response ─────────────────────────────────────
// HTTP/1.1 404 Not Found
// Content-Type: application/problem+json
//
// {
//   "type":      "https://api.myapp.com/errors/user-not-found",
//   "title":     "User Not Found",
//   "status":    404,
//   "detail":    "User not found with id: 42",
//   "instance":  "/api/v1/users/42",
//   "errorCode": "USER_001",
//   "timestamp": "2024-03-15T10:30:00Z"
// }

Standardised Error Response Contract

Whether using ProblemDetail or a custom envelope, pick one error response structure and apply it everywhere. A consistent contract lets clients handle errors generically without inspecting the URI or guessing the shape. Define the ErrorResponse record once and reference it from every exception handler.
Java
// ── ErrorResponse record ──────────────────────────────────────────────
public record ErrorResponse(
    int                  status,
    String               error,
    String               message,
    String               path,
    Instant              timestamp,
    Map<String, String>  fieldErrors
) {
    // ── Factory — no field errors ──────────────────────────────────────
    public static ErrorResponse of(int status, String error,
                                   String message, String path) {
        return new ErrorResponse(
            status, error, message, path, Instant.now(), Map.of());
    }

    // ── Factory — with field errors ───────────────────────────────────
    public static ErrorResponse ofFields(int status, String error,
                                         String message, String path,
                                         Map<String, String> fieldErrors) {
        return new ErrorResponse(
            status, error, message, path, Instant.now(), fieldErrors);
    }
}

// ── Usage in handlers ─────────────────────────────────────────────────
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
        EntityNotFoundException ex, HttpServletRequest req) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND)
        .body(ErrorResponse.of(404, "Not Found",
            ex.getMessage(), req.getRequestURI()));
}

// ── Example response ──────────────────────────────────────────────────
// {
//   "status":      404,
//   "error":       "Not Found",
//   "message":     "User not found with id: 42",
//   "path":        "/api/v1/users/42",
//   "timestamp":   "2024-03-15T10:30:00Z",
//   "fieldErrors": {}
// }

Mapping HTTP Client and I/O Errors

REST APIs often call downstream services. Map HTTP client errors, timeout exceptions, and I/O failures to appropriate status codes so upstream consumers receive meaningful responses rather than raw 500s. Handle these in @RestControllerAdvice alongside domain exceptions.
Java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // ── Downstream HTTP call returned an error ─────────────────────────
    @ExceptionHandler(HttpClientErrorException.class)
    public ResponseEntity<ErrorResponse> handleHttpClientError(
            HttpClientErrorException ex, HttpServletRequest req) {
        log.warn("Downstream HTTP error: {} {}", ex.getStatusCode(), ex.getMessage());
        return ResponseEntity.status(ex.getStatusCode())
            .body(ErrorResponse.of(
                ex.getStatusCode().value(),
                "Downstream Error",
                "A downstream service returned an error",
                req.getRequestURI()));
    }

    // ── Read / connect timeout ─────────────────────────────────────────
    @ExceptionHandler({ResourceAccessException.class,
                       SocketTimeoutException.class})
    public ResponseEntity<ErrorResponse> handleTimeout(
            Exception ex, HttpServletRequest req) {
        log.error("Downstream timeout: {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
            .body(ErrorResponse.of(504, "Gateway Timeout",
                "A downstream service did not respond in time",
                req.getRequestURI()));
    }

    // ── JSON parse error in request body ──────────────────────────────
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleUnreadable(
            HttpMessageNotReadableException ex, HttpServletRequest req) {
        return ResponseEntity.badRequest()
            .body(ErrorResponse.of(400, "Bad Request",
                "Malformed or unreadable request body",
                req.getRequestURI()));
    }

    // ── Method not allowed ────────────────────────────────────────────
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ErrorResponse> handleMethodNotAllowed(
            HttpRequestMethodNotSupportedException ex, HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
            .body(ErrorResponse.of(405, "Method Not Allowed",
                ex.getMessage(), req.getRequestURI()));
    }

    // ── Catch-all ─────────────────────────────────────────────────────
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAll(
            Exception ex, HttpServletRequest req) {
        log.error("Unhandled exception at {}", req.getRequestURI(), ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Internal Server Error",
                "An unexpected error occurred",
                req.getRequestURI()));
    }
}