Exception handling in a REST API has three parts: how the language models exceptions, where the application catches them, and what the API sends back to the caller. This note covers the three parts in Spring and in FastAPI.
How exceptions differ across languages
Java and Python organize exceptions in different class hierarchies. The hierarchy tells you which classes the application must not catch.
In Java, a method that calls code with a checked exception must catch it or declare it with throws. Many codebases wrap checked exceptions in RuntimeException, and Spring does the same. Python has no checked exceptions, so the compiler never forces you to handle one. In both languages the application code must not catch Error or BaseException.
Two common mistakes
The first mistake is to swallow the exception. The code catches it, logs nothing and returns a generic 500. The error leaves no trace in the logs, so nobody can find its cause.
The second mistake is to leak the exception. The code does not catch it, and the default handler of the framework puts the exception message or the full stack trace in the response. Every caller then sees internal class names, table names and parts of SQL queries.
The fix is one global handler. It catches every exception that the business code does not handle, logs it with the full stack trace, and returns a small structured error response.
Centralized handler: Spring
In Spring, a class annotated with @RestControllerAdvice handles the exceptions thrown by every controller. Spring chooses the @ExceptionHandler method with the most specific exception type. The Exception.class handler runs only when no other handler matches, wherever it is in the file.
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleGeneric(Exception ex, HttpServletRequest req) {
Throwable root = rootCause(ex);
log.error("Unhandled exception [{}]", req.getRequestURI(), ex); // ex as 3rd arg → full stack trace
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError("INTERNAL_ERROR", root.getMessage()));
}
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(EntityNotFoundException ex) {
log.warn("Not found: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ApiError("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiError> handleBadRequest(IllegalArgumentException ex) {
log.warn("Bad request: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ApiError("BAD_REQUEST", ex.getMessage()));
}
private static Throwable rootCause(Throwable t) {
return t.getCause() == null ? t : rootCause(t.getCause());
}
public record ApiError(String code, String message) {}
}
Look at log.error("...", req.getRequestURI(), ex). SLF4J prints the stack trace only when the exception is the last argument and no {} placeholder uses it. log.error("error: {}", ex) prints only ex.toString() and no stack trace. In production this gives a log line with NullPointerException and nothing else.
Centralized handler: FastAPI
FastAPI registers handlers with the @app.exception_handler decorator. The structure is the same as in Spring. There is one handler per exception type and one handler for Exception. The logs get the full traceback, and the response gets a short message.
import logging
import traceback
from fastapi import FastAPI, HTTPException, Request
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI()
log = logging.getLogger(__name__)
class ApiError(BaseModel):
code: str
message: str
def root_cause(exc: BaseException) -> BaseException:
return root_cause(exc.__cause__) if exc.__cause__ else exc
@app.exception_handler(Exception)
async def generic_handler(request: Request, exc: Exception) -> JSONResponse:
root = root_cause(exc)
log.error("Unhandled exception [%s]\n%s", request.url.path, traceback.format_exc())
return JSONResponse(
status_code=500,
content=ApiError(code="INTERNAL_ERROR", message=str(root)).model_dump(),
)
@app.exception_handler(HTTPException)
async def http_handler(request: Request, exc: HTTPException) -> JSONResponse:
return await http_exception_handler(request, exc) # preserve FastAPI's built-in 4xx behavior
class NotFoundException(Exception):
def __init__(self, message: str):
super().__init__(message)
self.message = message
@app.exception_handler(NotFoundException)
async def not_found_handler(request: Request, exc: NotFoundException) -> JSONResponse:
log.warning("Not found [%s]: %s", request.url.path, exc.message)
return JSONResponse(
status_code=404,
content=ApiError(code="NOT_FOUND", message=exc.message).model_dump(),
)
traceback.format_exc() returns the full traceback, with the chained causes, as one string. It works only while the exception is being handled, so call it inside an except block or inside the exception handler.
The HTTPException handler passes the exception back to the default FastAPI handler. A route that raises HTTPException(status_code=422, ...) then still returns a 422. Without this handler, the Exception handler catches it and returns a 500.
Extracting the root cause
When each layer wraps the exception of the layer below, the outer message is often generic, like "Transaction failed" or "Request error". The root cause is the first exception in the chain, and its message usually says what went wrong.
In Java
This recursive method covers most cases and needs no library:
private static Throwable rootCause(Throwable t) {
return t.getCause() == null ? t : rootCause(t.getCause());
}
If Apache Commons Lang (commons-lang3) is already on the classpath, use ExceptionUtils.getRootCause. It also handles a cause chain with a cycle:
// Maven: org.apache.commons:commons-lang3:3.14.0
import org.apache.commons.lang3.exception.ExceptionUtils;
Throwable root = ExceptionUtils.getRootCause(ex);
String message = root != null ? root.getMessage() : ex.getMessage();
String fullStack = ExceptionUtils.getStackTrace(ex); // full chain as one string
Without an extra dependency, use NestedExceptionUtils.getRootCause(ex) from spring-core. Every Spring Boot project already has it on the classpath.
In Python
To log the full chain, use traceback.format_exc():
import traceback
try:
do_something()
except Exception:
# Prints full chain including __cause__ and __context__
log.error("Failed:\n%s", traceback.format_exc())
To get the original exception object in code:
def root_cause(exc: BaseException) -> BaseException:
return root_cause(exc.__cause__) if exc.__cause__ else exc
# __cause__ → set by: raise NewError() from original
# __context__ → set implicitly when raise happens inside an except block
traceback.format_exception(exc) returns the same text as a list of strings. Use it when the stack trace goes into a JSON field of a structured log.
The error response
The response has three fields:
{
"code": "NOT_FOUND",
"message": "User with id 4821 was not found",
"timestamp": "2026-05-03T10:23:41Z"
}
The client reads code to decide what to do. Developers read message during integration and in dashboards. timestamp helps to find the matching log line. Stack traces, class names, SQL and internal paths go only to the logs.
In the Spring record, the timestamp gets a default value:
import java.time.Instant;
public record ApiError(String code, String message, Instant timestamp) {
public ApiError(String code, String message) {
this(code, message, Instant.now());
}
}
Quick reference
Exception type and response
404 Not FoundWARN400 Bad RequestWARN401 UnauthorizedWARN403 ForbiddenWARN500 Internal Server ErrorERRORRoot cause extraction
while (t.getCause()!=null) t=t.getCause();while exc.__cause__: exc=exc.__cause__NestedExceptionUtils.getRootCause(ex)exc.__cause__ or excExceptionUtils.getRootCause(ex)traceback.format_exc()ExceptionUtils.getStackTrace(ex)traceback.format_exception(exc)Response field contract
machine-readable keyNOT_FOUND · BAD_REQUEST · INTERNAL_ERRORhuman-readable causerootCause.getMessage() / str(root_cause(exc))log correlationInstant.now() / datetime.now(timezone.utc)stays in server logs onlynever in the response bodySLF4J: where the exception goes in the call
message and full stack traceonly ex.toString(), no stack traceonly "msg": the argument has no placeholder and is droppedThe handler still does not connect a 500 seen by a client to the log lines of that request. A correlation ID does that. It is a UUID created for each request and written to the response header and to every log line of the request. The observability note shows how to propagate it across services.