Exception handling in a REST API involves three separate problems that usually get conflated, and most codebases solve only one of them: what an exception is in the language we’re using, when and where to catch it, and what to send back to the caller. This note covers all three, with patterns that work in both Spring and FastAPI.
How exceptions differ across languages
Java and Python share the concept but diverge in design. The hierarchy determines what we must not catch.
In Java, every call site must either handle a checked exception or declare it upward. Most codebases wrap everything in RuntimeException, and Spring does the same internally. Python has no checked exceptions and leaves that discipline to us. Both languages share one rule: never catch Error or BaseException at the application layer.
The two failure modes
The first failure mode is swallowing: catching an exception, logging nothing, and returning a vague 500. The error leaves no trace, so it recurs without a diagnosis.
The second failure mode is leaking: letting the exception propagate to the framework’s default handler, which returns the raw exception message or a full stack trace in the response body. The response then exposes internal class names, database table names, and SQL fragments to every caller.
The fix is one place in the application that catches anything the business logic does not handle explicitly, logs the full exception internally (with stack trace), and returns a structured error response with a safe, informative message.
Centralized handler: Spring
In Spring, @RestControllerAdvice creates a global exception handler that intercepts exceptions thrown from any controller. @ExceptionHandler methods inside it are matched by Spring from most-specific to least-specific, so the catch-all Exception.class handler is always the last resort regardless of its position 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) {}
}
The key line is log.error("...", req.getRequestURI(), ex). SLF4J prints the stack trace only when the exception is passed as the last argument, outside the format string. log.error("error: {}", ex) prints only the exception’s toString() and drops the trace. The mistake surfaces in production as a log line that says NullPointerException and nothing else.
Centralized handler: FastAPI
FastAPI uses @app.exception_handler decorators. The structure mirrors Spring: one handler per exception type, a catch-all for everything else, full traceback in the logs, clean message in the response.
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() captures the full chain (including chained causes) as a single string. It has to be called inside the except block or the exception handler while the exception context is still active. Here FastAPI guarantees that.
The HTTPException re-delegation preserves FastAPI’s own handling for raise HTTPException(status_code=422, ...) calls inside route functions. Without it, the generic Exception handler would catch those too and return a 500.
Extracting the root cause
When exceptions wrap other exceptions through multiple layers, the message on the outer exception is often useless ("Transaction failed", "Request error"). The root cause, the original exception at the bottom of the chain, carries the informative message.
In Java
The three-line recursive version covers most cases and needs no dependencies:
private static Throwable rootCause(Throwable t) {
return t.getCause() == null ? t : rootCause(t.getCause());
}
When Apache Commons Lang (commons-lang3) is already on the classpath, ExceptionUtils.getRootCause handles edge cases like cycles in the cause chain and offers some extra utilities:
// 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
If we don’t want an extra dependency, Spring itself provides NestedExceptionUtils.getRootCause(ex) in the spring-core module, which is already on the classpath in any Spring Boot project.
In Python
For logging the full chain, traceback.format_exc() is all we need:
import traceback
try:
do_something()
except Exception:
# Prints full chain including __cause__ and __context__
log.error("Failed:\n%s", traceback.format_exc())
For programmatic access to the original exception:
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 output as a list of strings, useful when we want the stack trace as a JSON field in structured logging rather than a bare multiline string.
What the response should look like
Three fields cover everything a caller needs without exposing internals:
{
"code": "NOT_FOUND",
"message": "User with id 4821 was not found",
"timestamp": "2026-05-03T10:23:41Z"
}
code is machine-readable, and the client switches on it. message is human-readable, and developers read it in dashboards and during integration. timestamp correlates the response with a specific log line. Stack traces, class names, SQL fragments, and internal paths stay in the logs and never appear in the body.
Adding the timestamp to the Spring record:
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 → response mapping
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 logging — the one rule that breaks everything
full stack trace printed — correctonly ex.toString() printed — stack trace lostonly message string printed — stack trace lostThe handler above gives us clean error responses and full stack traces in the logs. What’s still missing is a way to connect a specific 500 in the dashboard back to the request that caused it. A correlation ID covers that: a request-scoped UUID written to both the response header and every log line of that request, so one search finds both sides.