Spring Boot Exception Handling
Every application meets errors: a book that does not exist, an invalid request, a database that is down. Good exception handling turns these moments into clear, consistent responses that clients can understand and act on, instead of confusing stack traces or generic “Something went wrong” pages.
This tutorial shows how Spring Boot handles errors out of the box, and how to take control with custom exceptions, @ExceptionHandler, @RestControllerAdvice and the standard ProblemDetail format, with logging, testing and best practices along the way.
1. Why Exception Handling Matters
Compare two responses to the same problem: a client asks for book 42, which does not exist.
java.util.NoSuchElementException: No value present
at java.base/java.util.Optional.orElseThrow(Optional.java:377)
at com.javabytechie.books.BookService.findById(BookService.java:28)
...
{
"type": "https://api.javabytechie.com/errors/book-not-found",
"title": "Book not found",
"status": 404,
"detail": "No book exists with id 42",
"instance": "/api/books/42"
}
The first response uses the wrong status code, leaks internal class names and helps nobody. The second tells the client exactly what happened, in a predictable format. Good exception handling gives you:
- Correct HTTP status codes, so clients and tools can react automatically.
- A consistent error format across every endpoint.
- Security: no stack traces or internal details sent to the outside world.
- Cleaner code: controllers and services focus on the happy path, and errors are handled in one place.
2. What Spring Boot Does by Default
If an exception escapes your controller and nothing handles it, Spring Boot forwards the request to its built-in /error endpoint. For a REST client, the response looks like this:
{
"timestamp": "2026-10-04T10:15:30.123+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/books/42"
}
Browsers see the “Whitelabel Error Page” instead. By default, the exception message and stack trace are hidden, which is the safe choice. You can change this with properties, but only do so in development:
server.error.include-message=always
server.error.include-binding-errors=always
server.error.include-stacktrace=on_param
The default is a safety net, not a design: every unhandled error becomes a 500 with a generic message. The rest of this tutorial shows how to return the right status and a helpful message for each situation.
3. Creating Custom Exceptions
Custom exceptions describe what went wrong in business terms, such as “book not found” or “out of stock”, rather than technical ones. A small hierarchy keeps them organised:
package com.javabytechie.books.error;
public abstract class ApplicationException extends RuntimeException {
protected ApplicationException(String message) {
super(message);
}
protected ApplicationException(String message, Throwable cause) {
super(message, cause);
}
}
public class BookNotFoundException extends ApplicationException {
private final Long bookId;
public BookNotFoundException(Long bookId) {
super("No book exists with id " + bookId);
this.bookId = bookId;
}
public Long getBookId() {
return bookId;
}
}
public class OutOfStockException extends ApplicationException {
public OutOfStockException(String isbn, int requested, int available) {
super("Only " + available + " copies of " + isbn + " are available, but " + requested + " were requested");
}
}
Throw them from the service layer, where the business rules live:
@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
@Transactional
public void reserve(String isbn, int quantity) {
Book book = bookRepository.findByIsbn(isbn).orElseThrow();
if (book.getStock() < quantity) {
throw new OutOfStockException(isbn, quantity, book.getStock());
}
book.setStock(book.getStock() - quantity);
}
}
Extending RuntimeException keeps method signatures clean, because unchecked exceptions do not need throws clauses. It also triggers a rollback in @Transactional methods by default.
4. Handling Exceptions in One Controller
An @ExceptionHandler method inside a controller handles exceptions thrown by that controller only:
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/{id}")
public Book getBook(@PathVariable Long id) {
return bookService.findById(id); // may throw BookNotFoundException
}
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<String> handleNotFound(BookNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
}
Notice that getBook() contains no try/catch. It simply lets the exception travel up, and the handler turns it into a response. This is useful for errors that only one controller can produce. For errors shared by many controllers, use a global handler instead.
5. Global Handling with @RestControllerAdvice
A class marked with @RestControllerAdvice holds exception handlers that apply to every controller in the application:
package com.javabytechie.books.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<String> handleNotFound(BookNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
@ExceptionHandler(OutOfStockException.class)
public ResponseEntity<String> handleOutOfStock(OutOfStockException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage());
}
}
@ControllerAdvice | @RestControllerAdvice | |
|---|---|---|
| Return value of handlers | A view name, unless the method has @ResponseBody or returns ResponseEntity | Written to the response body, usually as JSON |
| Best for | Web apps that render HTML pages | REST APIs |
| Relationship | The base annotation | @ControllerAdvice + @ResponseBody |
When several handlers could match, Spring picks the most specific exception type. A handler for BookNotFoundException wins over one for ApplicationException, which wins over one for Exception.
6. A Consistent Error Response
Plain strings are hard for clients to process. Return the same structured object for every error instead. A Java record keeps it short:
public record ApiError(
Instant timestamp,
int status,
String error,
String message,
String path
) {
public static ApiError of(HttpStatus status, String message, String path) {
return new ApiError(Instant.now(), status.value(), status.getReasonPhrase(), message, path);
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(BookNotFoundException ex, HttpServletRequest request) {
return build(HttpStatus.NOT_FOUND, ex.getMessage(), request);
}
@ExceptionHandler(OutOfStockException.class)
public ResponseEntity<ApiError> handleOutOfStock(OutOfStockException ex, HttpServletRequest request) {
return build(HttpStatus.CONFLICT, ex.getMessage(), request);
}
private ResponseEntity<ApiError> build(HttpStatus status, String message, HttpServletRequest request) {
return ResponseEntity.status(status).body(ApiError.of(status, message, request.getRequestURI()));
}
}
{
"timestamp": "2026-10-04T10:15:30.123Z",
"status": 409,
"error": "Conflict",
"message": "Only 2 copies of 9780134685991 are available, but 5 were requested",
"path": "/api/books/reservations"
}
Handler methods can receive useful extra parameters, such as HttpServletRequest, WebRequest or Locale. Spring supplies them automatically.
7. Using ProblemDetail (RFC 9457)
Instead of inventing your own format, you can use the internet standard for API errors: Problem Details, defined in RFC 9457. Spring Framework 6 supports it with the ProblemDetail class:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Book not found");
problem.setType(URI.create("https://api.javabytechie.com/errors/book-not-found"));
return problem;
}
@ExceptionHandler(OutOfStockException.class)
public ProblemDetail handleOutOfStock(OutOfStockException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Not enough stock");
problem.setProperty("retryable", false); // extra fields are allowed
return problem;
}
}
| Field | Meaning |
|---|---|
type | A URI that identifies the kind of problem; ideally a page that documents it |
title | A short, human-readable summary that is the same for every occurrence |
status | The HTTP status code |
detail | An explanation of this specific occurrence |
instance | The request path where the problem happened (filled in automatically) |
The response is sent with the content type application/problem+json. To make Spring’s own built-in exceptions use this format too, add one property:
spring.mvc.problemdetails.enabled=true
8. Extending ResponseEntityExceptionHandler
Spring MVC throws its own exceptions for problems such as malformed JSON or unsupported HTTP methods. ResponseEntityExceptionHandler is a base class that already handles all of them with the right status codes. Extend it, and override only what you want to change:
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// Add field-level details to validation errors
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> fieldErrors.putIfAbsent(error.getField(), error.getDefaultMessage()));
ProblemDetail problem = ex.getBody();
problem.setTitle("Validation failed");
problem.setProperty("errors", fieldErrors);
return handleExceptionInternal(ex, problem, headers, status, request);
}
// Your own exceptions sit alongside the inherited handlers
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
In Spring Boot 3 and later, the third parameter is HttpStatusCode. Older tutorials show HttpStatus, which no longer compiles. For more on validation errors, see the Spring Boot Validation tutorial.
9. Common Spring Exceptions and Their Status Codes
These are the exceptions you will meet most often, and the status each should produce. The ones from Spring MVC are already mapped by ResponseEntityExceptionHandler:
| Exception | Cause | Status |
|---|---|---|
MethodArgumentNotValidException | @Valid request body failed validation | 400 |
HttpMessageNotReadableException | Malformed or unreadable JSON | 400 |
MethodArgumentTypeMismatchException | Wrong type, e.g. /books/abc for a Long id | 400 |
MissingServletRequestParameterException | A required @RequestParam is missing | 400 |
HttpRequestMethodNotSupportedException | e.g. DELETE on a GET-only URL | 405 |
HttpMediaTypeNotSupportedException | Wrong Content-Type, e.g. XML sent to a JSON endpoint | 415 |
NoResourceFoundException | No controller or file matches the URL (Spring 6.1+) | 404 |
DataIntegrityViolationException | Database constraint broken, e.g. a duplicate ISBN | Map it to 409 yourself |
OptimisticLockingFailureException | Someone else updated the same record first | Map it to 409 yourself |
Database exceptions are not mapped automatically, so add handlers for them:
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleConflict(DataIntegrityViolationException ex) {
log.warn("Data integrity violation: {}", ex.getMostSpecificCause().getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The request conflicts with existing data.");
}
The log keeps the technical detail, such as which constraint failed, while the client gets a safe, general message.
10. The Catch-All Handler
Some exceptions are truly unexpected: a bug, a null pointer, a lost connection. A final handler for Exception catches everything the other handlers missed, so the client always gets your error format:
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
String errorId = UUID.randomUUID().toString();
log.error("Unexpected error {} on {} {}", errorId, request.getMethod(), request.getRequestURI(), ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred. Please contact support with error id " + errorId + ".");
problem.setTitle("Internal server error");
problem.setProperty("errorId", errorId);
return problem;
}
Three rules for this handler:
- Return 500, never 400. An unexpected error is the server’s fault, not the client’s.
- Never send
ex.getMessage()to the client. It may contain SQL, file paths or other internal details. - Log the full exception with an error ID, and give the same ID to the client. Support staff can then find the exact log entry in seconds.
Important: put this handler in a class that extends ResponseEntityExceptionHandler (section 8). Otherwise, the Exception handler also catches Spring’s own client errors, such as malformed JSON or a wrong parameter type, and turns these 400 responses into 500s.
11. @ResponseStatus and ResponseStatusException
For simple cases, Spring offers two shortcuts that do not need a handler method at all.
11.1 @ResponseStatus on an exception class
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "The ISBN must have 13 digits")
public class InvalidIsbnException extends RuntimeException {
}
Whenever this exception escapes a controller, Spring returns 400 with that reason. It is quick, but the message is fixed, so it cannot include details like the actual ISBN.
11.2 ResponseStatusException
@GetMapping("/isbn/{isbn}")
public Book getByIsbn(@PathVariable String isbn) {
if (!isbn.matches("\\d{13}")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ISBN must have 13 digits, got: " + isbn);
}
return bookService.findByIsbn(isbn);
}
| Approach | Best for |
|---|---|
@ResponseStatus on the class | Simple exceptions that always mean the same status and message |
ResponseStatusException | One-off errors in a controller, without creating a new class |
Custom exception + @RestControllerAdvice | Most applications: full control, consistent format, reusable |
12. Logging Exceptions the Right Way
Not every exception deserves the same log level. Logging a “book not found” as an ERROR with a full stack trace floods your logs and hides real problems.
| Kind of error | Log level | Stack trace? |
|---|---|---|
| Client mistakes (400, 404, 409) | WARN or INFO | No, the message is enough |
| Expected technical problems, e.g. a dependency timing out | WARN | Usually no |
| Unexpected errors (500) | ERROR | Yes, always |
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex, HttpServletRequest request) {
log.info("Book not found: {} ({})", ex.getMessage(), request.getRequestURI()); // no stack trace
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
Log each exception once, in the global handler. If services also log before rethrowing, the same error appears several times and is harder to follow.
13. Testing Exception Handling
Error responses are part of your API contract, so test them like any other response. A web slice test with MockMvc checks the status and body:
@WebMvcTest(BookController.class)
@Import(GlobalExceptionHandler.class)
class BookControllerErrorTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private BookService bookService;
@Test
void returns404WhenBookDoesNotExist() throws Exception {
when(bookService.findById(42L)).thenThrow(new BookNotFoundException(42L));
mockMvc.perform(get("/api/books/42"))
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.detail").value("No book exists with id 42"));
}
@Test
void returns400ForNonNumericId() throws Exception {
mockMvc.perform(get("/api/books/abc"))
.andExpect(status().isBadRequest());
}
}
@MockitoBean is available from Spring Boot 3.4; on older versions, use @MockBean.
14. Best Practices
- Handle errors in one place with a single
@RestControllerAdvice, instead oftry/catchblocks in every controller. - Create meaningful custom exceptions that describe business problems, and throw them from the service layer.
- Use the correct status code: 4xx when the client must change the request, 5xx when the server failed.
- Use one error format everywhere;
ProblemDetailis the standard choice. - Never expose internals: no stack traces, SQL, class names or file paths in responses.
- Always have a catch-all handler that returns 500 with an error ID.
- Log once, at the right level, with enough context to investigate.
- Test your error responses, because clients depend on them.
15. Common Mistakes to Avoid
| Mistake | What goes wrong | Fix |
|---|---|---|
| Catching exceptions in every controller | Duplicated code and inconsistent responses | One global @RestControllerAdvice |
| Returning 400 for unexpected errors | Clients think they sent a bad request and stop retrying | Return 500 for server-side failures |
Sending ex.getMessage() from any exception | Internal details leak to the outside world | Only return messages from your own exceptions |
| Returning 200 OK with an error inside the body | Clients and monitoring tools think the call succeeded | Use real HTTP status codes |
Swallowing exceptions with an empty catch | Errors disappear without a trace | Handle, rethrow or at least log them |
| Logging the same exception at every layer | One error appears many times in the logs | Log once, in the global handler |
Using HttpStatus in ResponseEntityExceptionHandler overrides | Does not compile on Spring Boot 3 | Use HttpStatusCode |
16. Summary
Spring Boot gives you a safe default for errors, and simple tools to do much better. Describe business problems with custom exceptions, throw them from your services, and turn them into correct status codes and a consistent ProblemDetail response in one @RestControllerAdvice. Extend ResponseEntityExceptionHandler to cover Spring’s own exceptions, add a catch-all that returns 500 with an error ID, log at the right level, and test your error responses like any other part of your API.
17. Frequently Asked Questions (FAQ)
Q1. Why is my @RestControllerAdvice not handling an exception?
Check these common causes:
- The advice class is not scanned. It must be in the package of your
@SpringBootApplicationclass or a sub-package. - The exception is caught earlier, for example by a
try/catchin the service that returnsnullinstead. - It was thrown in a filter, such as Spring Security, before the request reached a controller.
- Another handler matched first: a more specific handler, a local
@ExceptionHandlerin the controller, or another advice with higher priority. - The advice is limited with
basePackagesorassignableTypesthat do not include this controller.
Q2. Why are Spring Security 401 and 403 errors not handled by my advice?
Most security checks happen in filters, before any controller runs, so controller advice never sees them. Customise them in the security configuration instead:
http.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, e) -> // 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.accessDeniedHandler((request, response, e) -> // 403
response.sendError(HttpServletResponse.SC_FORBIDDEN)));
One exception: AccessDeniedException from @PreAuthorize on a controller method does reach your advice. Make sure your catch-all Exception handler does not turn it into a 500. Add a specific handler that returns 403.
Q3. Should my custom exceptions be checked or unchecked?
For most Spring applications, use unchecked exceptions that extend RuntimeException:
- Methods do not need
throwsclauses all the way up the call chain. @Transactionalrolls back automatically on unchecked exceptions, but not on checked ones by default.- A global handler deals with them anyway, so forcing every caller to catch them adds no value.
Checked exceptions still make sense in libraries, when the caller can genuinely recover, for example by choosing another file when one is missing.
Q4. Which status code fits a business rule violation: 400, 409 or 422?
| Status | Use it when | Example |
|---|---|---|
| 400 Bad Request | The request itself is malformed or invalid | Missing field, wrong date format |
| 409 Conflict | The request clashes with the current state of the data | Duplicate ISBN, book already reserved, version conflict |
| 422 Unprocessable Content | The request is well-formed, but breaks a business rule | Ordering 0 copies, a return date before the loan date |
Many teams simply use 400 for all input problems and 409 for conflicts with existing data. Whatever you choose, be consistent and document it.
Q5. How do I return a JSON 404 for URLs that do not exist?
Since Spring Framework 6.1 (Spring Boot 3.2), an unknown URL raises NoResourceFoundException, which you can handle like any other exception:
@ExceptionHandler(NoResourceFoundException.class)
public ProblemDetail handleUnknownUrl(NoResourceFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
"The requested resource does not exist.");
}
If you extend ResponseEntityExceptionHandler, this is already handled for you. On older versions, set spring.mvc.throw-exception-if-no-handler-found=true and spring.web.resources.add-mappings=false, then handle NoHandlerFoundException.
Q6. I have several @RestControllerAdvice classes. Which one runs first?
Without configuration, the order is not guaranteed. Set it with @Order; a lower number means higher priority:
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class PaymentExceptionHandler { ... }
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler { ... } // contains the catch-all
Spring checks the advice classes in order and uses the first one that has a matching handler. Keep the catch-all Exception handler in the lowest-priority class, or it will hide the specific handlers in other classes.
Q7. Can I apply an advice to only some controllers?
Yes. Limit it by package, by class, or by annotation:
@RestControllerAdvice(basePackages = "com.javabytechie.books.api.admin")
public class AdminExceptionHandler { ... }
@RestControllerAdvice(assignableTypes = {BookController.class, AuthorController.class})
public class CatalogExceptionHandler { ... }
@RestControllerAdvice(annotations = PublicApi.class)
public class PublicApiExceptionHandler { ... }
This helps when one application serves two kinds of clients, for example a public API that must use ProblemDetail and an internal admin API with its own format.
Q8. Does @Transactional still roll back if my advice handles the exception?
Yes. The exception passes through the transactional service method first, and the transaction is rolled back at that moment. Only afterwards does it reach the controller advice, which turns it into a response.
The opposite case is the trap: if you catch the exception inside the @Transactional method and do not rethrow it, the transaction commits as if nothing went wrong.
Q9. Are exceptions from @Async or @Scheduled methods handled by my advice?
No. Controller advice only handles exceptions from web requests. Background methods run on other threads, outside any request:
@Asyncmethods returningCompletableFuturestore the exception in the future; handle it withexceptionally().@Asyncmethods returningvoidneed anAsyncUncaughtExceptionHandler.@Scheduledmethods need their owntry/catch, or an error handler on the task scheduler.
Q10. How should I handle errors returned by other services I call?
Translate them into your own exceptions at the edge of your service. Do not simply pass another service’s status codes through, because they may make no sense to your clients:
public AuthorDto getAuthor(Long id) {
return restClient.get()
.uri("/api/authors/{id}", id)
.retrieve()
.onStatus(status -> status.value() == 404,
(request, response) -> { throw new AuthorNotFoundException(id); })
.onStatus(HttpStatusCode::is5xxServerError,
(request, response) -> { throw new AuthorServiceUnavailableException(); })
.body(AuthorDto.class);
}
A 404 from the author service means “author not found” to your client. A 500 from it should become a 503 from you, because your service is fine, but a dependency is not.
Q11. How can clients identify an error without parsing the message text?
Messages are for humans and may change. Give each error a stable, machine-readable code that clients can rely on:
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setType(URI.create("https://api.javabytechie.com/errors/out-of-stock"));
problem.setProperty("code", "BOOK_OUT_OF_STOCK");
return problem;
A mobile app can then check code == "BOOK_OUT_OF_STOCK" and show its own message, in the user’s language, without breaking when the English text is reworded.
Q12. How do I return error messages in the user’s language?
Store messages in messages.properties files and look them up with MessageSource. Spring passes the client’s Locale to the handler, based on the Accept-Language header:
# messages.properties
error.book.notFound=No book exists with id {0}
# messages_hi.properties
error.book.notFound=आईडी {0} वाली कोई किताब नहीं है
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex, Locale locale) {
String detail = messageSource.getMessage("error.book.notFound", new Object[]{ex.getBookId()}, locale);
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, detail);
}
Q13. Is it bad to use exceptions for normal control flow?
Yes. Exceptions are for exceptional situations. Creating them is relatively costly because Java records the stack trace, and using them for expected outcomes makes code harder to follow.
// Avoid: an exception for a normal "no" answer
try {
userService.findByEmail(email);
return true;
} catch (UserNotFoundException ex) {
return false;
}
// Prefer: ask the question directly
return userRepository.existsByEmail(email);
Throwing BookNotFoundException when a client requests a missing book is fine: it ends the request with an error. Using exceptions to drive loops or ordinary decisions is not.
Q14. How do I keep the original error when wrapping an exception?
Always pass the original exception as the cause. Otherwise, the real reason for the failure disappears from your logs:
try {
return pdfRenderer.render(invoice);
} catch (IOException ex) {
throw new InvoiceGenerationException("Could not render invoice " + invoice.getId(), ex); // keep ex
}
The log then shows both your message and the original IOException under “Caused by”. This is why the ApplicationException base class in this tutorial has a constructor that accepts a cause.
Q15. What happens if an @ExceptionHandler method itself throws an exception?
Spring cannot use your handler, so it falls back to its default error handling. The client usually gets a generic 500 response from the /error endpoint, and the new exception is logged.
Keep handlers simple and safe: no database calls, no remote calls, and careful null handling, for example when ex.getMessage() is null. A handler should never be a new source of failures.
Q16. Does exception handling work the same way in Spring WebFlux?
Mostly, yes. @RestControllerAdvice, @ExceptionHandler and ProblemDetail all work in WebFlux, and handlers may return Mono values.
Two differences to know:
- There is no
HttpServletRequest; useServerWebExchangeorServerHttpRequestas a handler parameter instead. - For errors outside controllers, such as in a
WebFilter, implement aWebExceptionHandler.
The reactive base class equivalent to the Spring MVC one is also called ResponseEntityExceptionHandler, but it lives in the org.springframework.web.reactive package.
Q17. How do I customize errors that happen outside controllers, such as in filters?
These errors go straight to Spring Boot’s /error endpoint. Change its JSON by providing your own ErrorAttributes bean:
@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest request, ErrorAttributeOptions options) {
Map<String, Object> attributes = super.getErrorAttributes(request, options);
attributes.put("support", "support@javabytechie.com");
attributes.remove("error");
return attributes;
}
}
Combined with a controller advice, every error, inside or outside controllers, then follows a format you control.
Q18. How do I replace the Whitelabel Error Page with my own HTML pages?
Add pages named after the status code. Spring Boot finds them automatically:
| File | Shown for |
|---|---|
src/main/resources/templates/error/404.html | 404, rendered by a template engine such as Thymeleaf |
src/main/resources/static/error/500.html | 500, as a static page |
src/main/resources/templates/error/4xx.html | Any 4xx status without its own page |
src/main/resources/templates/error/5xx.html | Any 5xx status without its own page |
These pages are only used for browser requests that accept HTML. API clients asking for JSON still receive JSON.
Q19. Should I return ResponseEntity, ProblemDetail, or use @ResponseStatus on the handler?
All three work. Choose based on what you need to control:
| Handler returns | Use it when |
|---|---|
ProblemDetail | You use the standard error format; the status comes from the object itself |
ResponseEntity<T> | You also need to set headers, such as Retry-After |
A plain object + @ResponseStatus on the method | A custom body type with a fixed status and no headers |
@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<ProblemDetail> handleRateLimit(RateLimitExceededException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.TOO_MANY_REQUESTS, ex.getMessage());
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, "60")
.body(problem);
}
Q20. Should error responses include a trace or request ID?
Yes, it is very helpful. When a user reports a problem, the ID leads you straight to the matching log lines across all your services. With Micrometer Tracing on the classpath, the current trace ID is easy to add:
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
String traceId = Optional.ofNullable(tracer.currentSpan())
.map(span -> span.context().traceId())
.orElse("unknown");
log.error("Unexpected error, traceId={}", traceId, ex);
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problem.setDetail("An unexpected error occurred.");
problem.setProperty("traceId", traceId);
return problem;
}
A trace ID reveals nothing sensitive, so it is safe to share with clients. Without tracing, a random error ID works just as well, as shown in section 10.