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()));
}
}