Notebook / Backend / 003
note entry no. 003 · May 03, 2026

Exception handling in REST APIs: from concept to root cause

After twenty years of code review, the same pattern breaks teams: exceptions get swallowed or leaked raw. A centralized handler with a clear contract fixes both, and it works the same way in Spring and FastAPI.

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.

Java JVM / Spring
root of all things Throwable
never catch Error (OutOfMemoryError, StackOverflowError…)
checked exceptions Exception — must declare with throws or catch
unchecked exceptions RuntimeException — no declaration required
cause chain getCause() — wrap on rethrow with new RuntimeException("msg", cause)
Spring preference unchecked only — all framework exceptions extend RuntimeException
Python CPython / FastAPI
root of all things BaseException
never catch SystemExit · KeyboardInterrupt · GeneratorExit
all application exceptions Exception — catch this, not BaseException
checked exceptions none — everything is unchecked by design
explicit cause chain raise NewError("msg") from original — sets __cause__
implicit cause chain raise inside except block — sets __context__

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

exception type
HTTP status
log level
resource not found
404 Not Found
WARN
validation / bad input
400 Bad Request
WARN
missing credentials
401 Unauthorized
WARN
insufficient permissions
403 Forbidden
WARN
unhandled / unknown
500 Internal Server Error
ERROR

Root cause extraction

approach
Java / Spring
Python / FastAPI
manual traversal
while (t.getCause()!=null) t=t.getCause();
while exc.__cause__: exc=exc.__cause__
no extra dependency
NestedExceptionUtils.getRootCause(ex)
exc.__cause__ or exc
library
ExceptionUtils.getRootCause(ex)
traceback.format_exc()
full stack as string
ExceptionUtils.getStackTrace(ex)
traceback.format_exception(exc)

Response field contract

field
purpose
value source
code
machine-readable key
NOT_FOUND · BAD_REQUEST · INTERNAL_ERROR
message
human-readable cause
rootCause.getMessage() / str(root_cause(exc))
timestamp
log correlation
Instant.now() / datetime.now(timezone.utc)
stack trace
stays in server logs only
never in the response body

SLF4J logging — the one rule that breaks everything

pattern
result
log.error("msg", ex)
full stack trace printed — correct
log.error("msg: {}", ex)
only ex.toString() printed — stack trace lost
log.error("msg", ex.getMessage())
only message string printed — stack trace lost

The 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.

VM

V. M. Casale

backend / cloud / things that go bump in the night

I keep an engineering notebook of the small fixes, environment tricks, and infrastructure patterns that quietly make my work-week better.

Read next.