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 developers solve one while leaving the other two untouched.
The three problems: understanding what an exception is in your language; knowing when and where to catch it; and deciding what to tell the caller when you do. This note covers all three, with patterns you can drop into a Spring or FastAPI project today.
How exceptions differ across languages
Java and Python share the concept but diverge significantly in design. Knowing the hierarchy is what tells you what not to catch.
The practical consequence: in Java you must decide at each call site whether to handle a checked exception or declare it upward. In practice, most codebases wrap everything in RuntimeException anyway — which is exactly what Spring does internally. Python skips this entirely; the responsibility falls on discipline. 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 you will not 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 exposed to whoever is 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.
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 must 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 you 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());
}
If you are already using Apache Commons Lang (commons-lang3), ExceptionUtils.getRootCause handles edge cases like cycles in the cause chain and offers additional 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 you do not want an extra dependency, Spring itself provides NestedExceptionUtils.getRootCause(ex) in the spring-core module — already on the classpath in any Spring Boot project.
In Python
For logging the full chain, traceback.format_exc() is all you 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 you 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 you 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 is the foundation. Once it is in place, the natural next step is adding a correlation ID — a request-scoped UUID attached to both the response header and the log line. When a 500 lands in a dashboard, that ID collapses the entire request trace to a single search.