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

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

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

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.