Home/Learn/FastAPI/Error Handling — Custom Exceptions & Handlers

Error Handling — Custom Exceptions & Handlers

Intermediate
Routing & Structure

Raise domain exceptions from services; @app.exception_handler converts them to HTTP responses in one place. Override the validation handler for a consistent error envelope, and never leak stack traces.

Overview

HTTPException works everywhere but couples your business logic to HTTP. The cleaner pattern: services raise domain exceptions (StudentNotFound, SeatLimitReached) that know nothing about status codes, and a registered @app.exception_handler translates each into the right response — one mapping, applied consistently across every router. You can also override FastAPI's built-in handlers: RequestValidationError to reshape 422s into your API's standard envelope, and a catch-all Exception handler that logs the stack trace with a request ID but returns a bland 500 — the trace itself never crosses the wire. The result is the thing frontend teams actually ask for: every error, expected or not, has the same JSON shape.

Domain Exceptions + exception_handler

The service raises meaning; the handler assigns HTTP. Adding a new error type means one exception class and one handler entry — no touching routers.

Services raise meaning; one handler maps it to HTTP
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

# ── domain exceptions (services/exceptions.py) — no HTTP here ──
class DomainError(Exception):
    def __init__(self, message: str):
        self.message = message

class StudentNotFound(DomainError): pass
class SeatLimitReached(DomainError): pass
class DriveClosed(DomainError): pass

# ── one translation table (main.py / core/errors.py) ──
STATUS = {StudentNotFound: 404, SeatLimitReached: 409, DriveClosed: 410}

@app.exception_handler(DomainError)
def domain_error_handler(request: Request, exc: DomainError):
    return JSONResponse(
        status_code=STATUS.get(type(exc), 400),
        content={"error": type(exc).__name__, "detail": exc.message},
    )

# ── service code stays pure ──
def register_for_drive(student_id: int, drive_id: int):
    if student_id not in {1, 2}:
        raise StudentNotFound(f"student {student_id} does not exist")
    if drive_id == 7:
        raise SeatLimitReached("drive 7 is full (120/120)")
    return {"registered": True}

@app.post("/drives/{drive_id}/register/{student_id}")
def register(drive_id: int, student_id: int):
    return register_for_drive(student_id, drive_id)

# POST /drives/7/register/1 → 409 {"error":"SeatLimitReached","detail":"drive 7 is full (120/120)"}
# POST /drives/2/register/9 → 404 {"error":"StudentNotFound","detail":"student 9 does not exist"}

One Envelope for Everything — 422s and 500s Included

Override RequestValidationError so validation failures use your envelope too, and add a last-resort handler that logs the real error (with request ID) but never leaks it. Clients then parse ONE error shape.

Same JSON shape for 4xx, 422 and 500 — trace stays in the logs
import logging, uuid
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()
log = logging.getLogger("api")

# 1. Reshape FastAPI's 422 into the same envelope as domain errors
@app.exception_handler(RequestValidationError)
def validation_handler(request: Request, exc: RequestValidationError):
    fields = [
        {"field": ".".join(str(p) for p in e["loc"][1:]), "issue": e["msg"]}
        for e in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content={"error": "ValidationError", "detail": "invalid input", "fields": fields},
    )
# → {"error":"ValidationError","detail":"invalid input",
#    "fields":[{"field":"items.1.qty","issue":"Input should be ≥ 1"}]}

# 2. Last resort: log fully, respond blandly
@app.exception_handler(Exception)
def unhandled_handler(request: Request, exc: Exception):
    error_id = uuid.uuid4().hex[:8]
    log.exception("unhandled error id=%s path=%s", error_id, request.url.path)
    return JSONResponse(
        status_code=500,
        content={"error": "InternalError",
                 "detail": f"something went wrong (ref: {error_id})"},
    )
# The user gets a reference id to quote to support;
# the stack trace exists ONLY in your logs. Leaking tracebacks to clients
# exposes file paths, library versions, SQL — free intel for attackers.

Key Points to Remember

  • 1Domain exceptions in services; @app.exception_handler maps them to status codes
  • 2Override RequestValidationError to fit 422s into your standard envelope
  • 3Catch-all Exception handler: log with a reference id, return a bland 500
  • 4Never leak stack traces, SQL, or file paths in responses

Interview Questions

Sign in to ask Aria
1

Design consistent error responses across a 40-endpoint API — where does the mapping live?

MediumJuspay
2

Why should a service layer raise StudentNotFound instead of HTTPException(404)?

MediumAtlassian
3

What is dangerous about returning str(exception) in a 500 response?

MediumGoogle

Ask Aria about Error Handling — Custom Exceptions & Handlers

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…