Error Handling — Custom Exceptions & Handlers
IntermediateRaise 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.
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.
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 AriaDesign consistent error responses across a 40-endpoint API — where does the mapping live?
Why should a service layer raise StudentNotFound instead of HTTPException(404)?
What is dangerous about returning str(exception) in a 500 response?
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.