After twenty years writing and reviewing production code, one failure mode comes up more than any other: exception handling. Not because exceptions are conceptually hard, but because there are three separate problems that get conflated, and most of the time only one of them gets solved.
The three problems: what an exception actually is in the language we’re using, when and where to catch it, and what to send back to the caller when we do. 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 significantly in design. Knowing the hierarchy is what tells us what not to catch.
The practical consequence: in Java we have to decide at each call site whether to handle a checked exception or declare it upward. In practice most codebases wrap everything in RuntimeException anyway, and Spring does the same internally. Python skips that entirely and leaves the 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 becomes invisible. It happened, it will happen again, and we won’t know why.
The second failure mode is leaking: letting the exception propagate to the framework’s default handler, which typically returns the raw exception message or a full stack trace in the response body. Internal class names, database table names, SQL query fragments. All of it exposed to whoever’s calling the API.
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). Passing the exception as the last argument to any SLF4J method is what triggers stack trace printing. Passing it as a formatted argument (log.error("error: {}", ex)) prints only the exception’s toString(), losing the trace entirely. This trips people up constantly. We have a handler, we’re logging, we think we’re covered, and then a production incident arrives with just NullPointerException in the logs and nothing else to go on.
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, is what actually tells us what went wrong.
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: the client switches on it. message is human-readable: developers read it in dashboards and during integration. timestamp correlates the response to a specific log line when we need to trace what happened. Stack traces, class names, SQL fragments, and internal paths stay in the logs. Never 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. That’s a correlation ID: a request-scoped UUID in the response header and the log line. One ID, one search.