FastAPI Mastery
Topic 9 of 22

Exception Handling

Learn how to raise clean HTTP errors, define your own exception classes, and register global handlers that shape every error response your API ever returns. Done right, exception handling makes your API predictable, debuggable, and client-friendly.

9.1

HTTPException

HTTPException is FastAPI's built-in way to stop a request and immediately send an error response. You raise it anywhere in your route or dependency and FastAPI catches it, converts it to JSON, and sets the right HTTP status code — all automatically.

Request arrives
Route / Dependency raises HTTPException
FastAPI catches it
JSON error response sent
🔢
Status Codes

HTTP status codes are 3-digit numbers that tell the client what happened. FastAPI uses the status_code parameter of HTTPException. Always import status from fastapi — it gives you readable constants instead of magic numbers.

CodeMeaningUse when…
400Bad RequestMalformed input the client sent
401UnauthorizedNo / invalid credentials provided
403ForbiddenCredentials valid but not allowed
404Not FoundResource doesn't exist
409ConflictDuplicate resource / state conflict
422UnprocessablePydantic validation failures (auto)
500Internal ErrorUnhandled server-side bug
Python
from fastapi import FastAPI, HTTPException, status

app = FastAPI()

# Fake DB
items = {1: "Sword", 2: "Shield"}

@app.get("/items/{item_id}")
async def get_item(item_id: int):
    if item_id not in items:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,  # readable constant
            detail=f"Item {item_id} not found"
        )
    return {"item": items[item_id]}

# GET /items/99  →  {"detail": "Item 99 not found"}  HTTP 404
# GET /items/1   →  {"item": "Sword"}               HTTP 200
💡
Always prefer status.HTTP_404_NOT_FOUND over the raw integer 404. It makes code self-documenting and catches typos at import time.
💬
Error Messages & Headers

The detail field can be a string, a dict, or even a list — FastAPI JSON-encodes whatever you pass. You can also attach custom response headers via the headers parameter (useful for authentication challenges like WWW-Authenticate).

Python
from fastapi import FastAPI, HTTPException, status

app = FastAPI()

# ── 1. Plain string detail ──────────────────────────
@app.get("/a")
async def route_a():
    raise HTTPException(
        status_code=400,
        detail="username must be at least 3 characters"
    )
# Response: {"detail": "username must be at least 3 characters"}

# ── 2. Dict detail (structured error) ───────────────
@app.get("/b")
async def route_b():
    raise HTTPException(
        status_code=422,
        detail={
            "field": "email",
            "error": "not a valid email address",
            "input": "not-an-email"
        }
    )

# ── 3. Custom headers ────────────────────────────────
@app.get("/protected")
async def protected():
    raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Not authenticated",
        headers={"WWW-Authenticate": "Bearer"}
        # Browser will see this header in the response
    )
ℹ️
FastAPI always wraps your detail value in {"detail": ...} — this is the default shape. You can override this shape entirely with a global exception handler (covered in 9.3).
▶ Interactive — simulate HTTPException responses
Click a button to see the simulated HTTP response ↑
9.2

Custom Exceptions

Raw HTTPExceptions scattered everywhere mix HTTP concerns into your business logic. The better pattern: define your own exception classes for domain scenarios, then let a single global handler convert them to HTTP responses.

⚠️
Why not just raise HTTPException everywhere? Because your service layer shouldn't know about HTTP. If you later expose the same logic via WebSocket or CLI, you'll want domain exceptions that are transport-agnostic.
🏛️
Domain Exceptions

Domain exceptions represent business rule violations — things that go wrong in your application logic, not at the HTTP layer. Create a hierarchy rooted at a base class, then define specific subclasses for each scenario.

Python — exceptions/domain.py
# ── Step 1: define the exception hierarchy ──────────

class AppException(Exception):
    """Base for all application-level exceptions."""
    def __init__(self, message: str, code: str = "app_error"):
        self.message = message
        self.code = code            # machine-readable error code
        super().__init__(message)

class NotFoundException(AppException):
    def __init__(self, resource: str, id: int | str):
        super().__init__(
            message=f"{resource} with id={id} not found",
            code="not_found"
        )

class ConflictException(AppException):
    def __init__(self, message: str):
        super().__init__(message, code="conflict")

class ForbiddenException(AppException):
    def __init__(self, message: str = "Access denied"):
        super().__init__(message, code="forbidden")
Python — service layer (no HTTP imports!)
# ── Step 2: service raises domain exceptions ────────
from exceptions.domain import NotFoundException, ConflictException

USERS = {1: {"name": "Alice", "email": "alice@example.com"}}

class UserService:
    def get_user(self, user_id: int) -> dict:
        user = USERS.get(user_id)
        if not user:
            raise NotFoundException("User", user_id)  # domain exception
        return user

    def create_user(self, email: str) -> dict:
        existing = [u for u in USERS.values() if u["email"] == email]
        if existing:
            raise ConflictException(f"Email {email} already registered")
        # ... create and return user
💡
The service layer never imports from FastAPI. This keeps your business logic fully testable without spinning up an HTTP server.
Validation Exceptions

FastAPI automatically raises RequestValidationError when Pydantic fails to parse the incoming request body. You can catch and reformat this error to give clients friendlier messages. You can also define your own validation exception for business-rule validations.

Python — default Pydantic error shape (422)
# What FastAPI sends automatically when a body fails Pydantic validation:
{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "email"],
      "msg": "Field required",
      "input": {"name": "Alice"}
    }
  ]
}
# The "loc" array shows exactly which field failed — very useful for clients
Python — custom validation exception class
# For business-rule validation (not Pydantic), define your own:

class ValidationException(AppException):
    """Raised when business rules reject otherwise-valid input."""
    def __init__(self, field: str, message: str):
        self.field = field
        super().__init__(message=message, code="validation_error")

# Example usage in a service:
def set_age(age: int):
    if age < 0 or age > 150:
        raise ValidationException(
            field="age",
            message="Age must be between 0 and 150"
        )
Python — override Pydantic's 422 error format
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError
):
    # Reformat the Pydantic errors into a cleaner structure
    errors = [
        {
            "field": " → ".join(str(loc) for loc in err["loc"]),
            "message": err["msg"],
            "type": err["type"]
        }
        for err in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content={"status": "error", "errors": errors}
    )

# POST /users with missing email now returns:
# {"status": "error", "errors": [{"field":"body → email","message":"Field required","type":"missing"}]}
9.3

Global Exception Handlers

Instead of catching exceptions in every route, register global handlers with @app.exception_handler(ExceptionClass). FastAPI calls the matching handler whenever that exception type propagates out of any route, dependency, or middleware.

Route raises exception
FastAPI walks handlers
Matching handler formats response
Client receives JSON
🔗
Exception Registration

Use @app.exception_handler(SomeException) to register a handler. The handler receives the Request and the exception instance, and must return a Response object. You can register handlers for:

HTTPException RequestValidationError Your own domain exceptions Exception (catch-all)
Python — main.py (complete example)
from fastapi import FastAPI, Request, HTTPException, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

# --- our domain exceptions ---
class AppException(Exception):
    def __init__(self, message: str, code: str, http_status: int):
        self.message = message
        self.code = code
        self.http_status = http_status
        super().__init__(message)

class NotFoundException(AppException):
    def __init__(self, resource: str, id):
        super().__init__(
            message=f"{resource} #{id} not found",
            code="not_found",
            http_status=404
        )

class ConflictException(AppException):
    def __init__(self, msg: str):
        super().__init__(msg, "conflict", 409)


app = FastAPI()

# ── Handler 1: our domain exceptions ────────────────
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
    return JSONResponse(
        status_code=exc.http_status,
        content={
            "status": "error",
            "code":   exc.code,
            "detail": exc.message,
            "path":   str(request.url)
        }
    )

# ── Handler 2: Pydantic validation errors ────────────
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=422,
        content={
            "status": "error",
            "code":   "validation_error",
            "errors": exc.errors()
        }
    )

# ── Handler 3: catch-all for unhandled exceptions ────
@app.exception_handler(Exception)
async def generic_handler(request: Request, exc: Exception):
    return JSONResponse(
        status_code=500,
        content={
            "status": "error",
            "code":   "internal_error",
            "detail": "An unexpected error occurred"
            # Never leak exc details in production!
        }
    )


# ── Routes that use domain exceptions ────────────────
USERS = {1: {"name": "Alice"}}

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    if user_id not in USERS:
        raise NotFoundException("User", user_id)  # ← domain exception
    return USERS[user_id]
🚨
Never expose raw exception messages in production — they can leak internal paths, DB schemas, or library versions. Show a generic message and log the full stack trace server-side.
🎨
Error Formatting — Consistent API Error Shape

A good API always returns errors in the same predictable shape. This lets client developers write a single error-handling function instead of special-casing every endpoint. Here's a recommended standardised error schema:

JSON — recommended error envelope
{
  "status": "error",              // always "error" for failures
  "code":   "not_found",           // machine-readable snake_case code
  "detail": "User #99 not found",  // human-readable message
  "path":   "/users/99",           // the URL that was called
  "request_id": "a1b2c3d4"        // trace/correlation ID (optional)
}
Python — complete production-ready error handler module
# errors/handlers.py
import uuid, logging
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

logger = logging.getLogger(__name__)

def _error_response(status_code: int, code: str, detail: str,
                     request: Request, extra: dict = {}) -> JSONResponse:
    request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8])
    return JSONResponse(
        status_code=status_code,
        content={
            "status":     "error",
            "code":       code,
            "detail":     detail,
            "path":       str(request.url.path),
            "request_id": request_id,
            **extra
        }
    )

def register_exception_handlers(app: FastAPI) -> None:
    """Call this once from main.py to wire up all handlers."""

    @app.exception_handler(HTTPException)
    async def http_handler(request: Request, exc: HTTPException):
        code_map = {
            400: "bad_request", 401: "unauthorized",
            403: "forbidden",   404: "not_found",
            409: "conflict",    500: "internal_error"
        }
        code = code_map.get(exc.status_code, f"http_{exc.status_code}")
        return _error_response(exc.status_code, code, exc.detail, request)

    @app.exception_handler(RequestValidationError)
    async def validation_handler(request: Request, exc: RequestValidationError):
        errors = [
            {"field": ".".join(str(l) for l in e["loc"]), "msg": e["msg"]}
            for e in exc.errors()
        ]
        return _error_response(422, "validation_error",
                               "Request validation failed", request,
                               {"errors": errors})

    @app.exception_handler(Exception)
    async def generic_handler(request: Request, exc: Exception):
        logger.exception("Unhandled exception", exc_info=exc)
        return _error_response(500, "internal_error",
                               "An unexpected error occurred", request)


# main.py
from fastapi import FastAPI
from errors.handlers import register_exception_handlers

app = FastAPI()
register_exception_handlers(app)  # one line wires everything up
Best practices checklist:
• Always return the same JSON shape for all errors
• Include a machine-readable code (snake_case), not just a status code
• Add a request_id / trace ID so logs can be correlated
• Log full stack trace server-side, never expose it in the response body
• Use a catch-all Exception handler to prevent raw 500 HTML leaking

Full exception-handling architecture at a glance:

Route/Dependency
│ raises NotFoundException (domain exception, no HTTP imports)


FastAPI exception handling
│ looks for handler registered for AppException (base class) ← matches!


app_exception_handler()
│ builds uniform JSON body + correct HTTP status code


JSONResponse(status_code=404, content={...}) → Client
▶ Interactive — pick a scenario, see the error response
Click a scenario button to see the formatted error response ↑
📋
Topic 9 — Quick Reference Summary
ToolWhen to useKey import
HTTPException Quick HTTP errors inside routes — simple cases from fastapi import HTTPException
status.HTTP_xxx Always — instead of magic integers from fastapi import status
Domain Exception classes Service/repository layer — no HTTP imports your own exceptions/ module
RequestValidationError Override Pydantic's default 422 format from fastapi.exceptions import RequestValidationError
@app.exception_handler Register global handlers — catch any exception type decorator on the app instance
Catch-all Exception handler Prevent raw 500 HTML / traceback leaking to clients @app.exception_handler(Exception)
Up next
Topic 10 — Authentication & Authorization
API Keys · JWT · OAuth2 · RBAC · ABAC