003

Centralized exception handling in REST APIs with Spring and FastAPI

One global exception handler logs the full stack trace and returns a small JSON error with a code, a message and a timestamp. The same design in Spring with @RestControllerAdvice and in FastAPI with exception handlers.

problem

An API often handles exceptions in one of two bad ways. It catches them and logs nothing, so nobody can find the error later. Or it lets them reach the client, and the response shows class names, table names and SQL.

Exception handling in a REST API has three parts: how the language models exceptions, where the application catches them, and what the API sends back to the caller. This note covers the three parts in Spring and in FastAPI.


How exceptions differ across languages

Java and Python organize exceptions in different class hierarchies. The hierarchy tells you which classes the application must not catch.

Java JVM / Spring
root class Throwable
never catch Error (OutOfMemoryError, StackOverflowError…)
checked exceptions Exception: declare it with throws or catch it
unchecked exceptions RuntimeException: no declaration needed
cause chain getCause(); keep the cause when you rethrow: new RuntimeException("msg", cause)
Spring preference unchecked only; Spring exceptions extend RuntimeException
Python CPython / FastAPI
root class BaseException
never catch SystemExit · KeyboardInterrupt · GeneratorExit
all application exceptions Exception: catch this class, never BaseException
checked exceptions none; all exceptions are unchecked
explicit cause chain raise NewError("msg") from original sets __cause__
implicit cause chain a raise inside an except block sets __context__

In Java, a method that calls code with a checked exception must catch it or declare it with throws. Many codebases wrap checked exceptions in RuntimeException, and Spring does the same. Python has no checked exceptions, so the compiler never forces you to handle one. In both languages the application code must not catch Error or BaseException.


Two common mistakes

The first mistake is to swallow the exception. The code catches it, logs nothing and returns a generic 500. The error leaves no trace in the logs, so nobody can find its cause.

The second mistake is to leak the exception. The code does not catch it, and the default handler of the framework puts the exception message or the full stack trace in the response. Every caller then sees internal class names, table names and parts of SQL queries.

The fix is one global handler. It catches every exception that the business code does not handle, logs it with the full stack trace, and returns a small structured error response.


Centralized handler: Spring

In Spring, a class annotated with @RestControllerAdvice handles the exceptions thrown by every controller. Spring chooses the @ExceptionHandler method with the most specific exception type. The Exception.class handler runs only when no other handler matches, wherever it is 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) {}
}

Look at log.error("...", req.getRequestURI(), ex). SLF4J prints the stack trace only when the exception is the last argument and no {} placeholder uses it. log.error("error: {}", ex) prints only ex.toString() and no stack trace. In production this gives a log line with NullPointerException and nothing else.


Centralized handler: FastAPI

FastAPI registers handlers with the @app.exception_handler decorator. The structure is the same as in Spring. There is one handler per exception type and one handler for Exception. The logs get the full traceback, and the response gets a short message.

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() returns the full traceback, with the chained causes, as one string. It works only while the exception is being handled, so call it inside an except block or inside the exception handler.

The HTTPException handler passes the exception back to the default FastAPI handler. A route that raises HTTPException(status_code=422, ...) then still returns a 422. Without this handler, the Exception handler catches it and returns a 500.


Extracting the root cause

When each layer wraps the exception of the layer below, the outer message is often generic, like "Transaction failed" or "Request error". The root cause is the first exception in the chain, and its message usually says what went wrong.

In Java

This recursive method covers most cases and needs no library:

private static Throwable rootCause(Throwable t) {
    return t.getCause() == null ? t : rootCause(t.getCause());
}

If Apache Commons Lang (commons-lang3) is already on the classpath, use ExceptionUtils.getRootCause. It also handles a cause chain with a cycle:

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

Without an extra dependency, use NestedExceptionUtils.getRootCause(ex) from spring-core. Every Spring Boot project already has it on the classpath.

In Python

To log the full chain, use traceback.format_exc():

import traceback

try:
    do_something()
except Exception:
    # Prints full chain including __cause__ and __context__
    log.error("Failed:\n%s", traceback.format_exc())

To get the original exception object in code:

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 text as a list of strings. Use it when the stack trace goes into a JSON field of a structured log.


The error response

The response has three fields:

{
  "code": "NOT_FOUND",
  "message": "User with id 4821 was not found",
  "timestamp": "2026-05-03T10:23:41Z"
}

The client reads code to decide what to do. Developers read message during integration and in dashboards. timestamp helps to find the matching log line. Stack traces, class names, SQL and internal paths go only to the logs.

In the Spring record, the timestamp gets a default value:

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

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: where the exception goes in the call

pattern
result
log.error("msg", ex)
message and full stack trace
log.error("msg: {}", ex)
only ex.toString(), no stack trace
log.error("msg", ex.getMessage())
only "msg": the argument has no placeholder and is dropped

The handler still does not connect a 500 seen by a client to the log lines of that request. A correlation ID does that. It is a UUID created for each request and written to the response header and to every log line of the request. The observability note shows how to propagate it across services.