Error Handling and HTTP Exceptions
Good APIs fail gracefully. Every error path should return a structured, consistent response so clients can handle it programmatically — not a cryptic 500 with an HTML stack trace.
By the end of this lesson you can: raise HTTPException with custom details, override default 422 and 500 handlers, define application-level custom exceptions with @app.exception_handler, and return consistent error envelopes.
Raising HTTPException
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/{item_id}")
async def get_item(item_id: int) -> dict:
db = {1: {"name": "Widget"}}
if item_id not in db:
raise HTTPException(
status_code=404,
detail=f"Item with ID {item_id} not found",
)
return db[item_id]
The detail field can be a string, dict, or list — all are JSON-serialized:
raise HTTPException(
status_code=422,
detail={"code": "INVALID_PRICE", "message": "Price must be positive"},
)
Custom Exception Classes
Define domain exceptions and map them to HTTP responses:
class ItemNotFoundError(Exception):
def __init__(self, item_id: int):
self.item_id = item_id
class InsufficientStockError(Exception):
def __init__(self, product_id: int, requested: int, available: int):
self.product_id = product_id
self.requested = requested
self.available = available
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.exceptions import ItemNotFoundError, InsufficientStockError
app = FastAPI()
@app.exception_handler(ItemNotFoundError)
async def item_not_found_handler(request: Request, exc: ItemNotFoundError) -> JSONResponse:
return JSONResponse(
status_code=404,
content={"error": "ITEM_NOT_FOUND", "item_id": exc.item_id},
)
@app.exception_handler(InsufficientStockError)
async def insufficient_stock_handler(
request: Request, exc: InsufficientStockError
) -> JSONResponse:
return JSONResponse(
status_code=409,
content={
"error": "INSUFFICIENT_STOCK",
"product_id": exc.product_id,
"requested": exc.requested,
"available": exc.available,
},
)
from app.exceptions import ItemNotFoundError, InsufficientStockError
@router.post("/")
async def place_order(order: OrderCreate) -> dict:
for line in order.lines:
if line.product_id not in _inventory:
raise ItemNotFoundError(line.product_id)
if _inventory[line.product_id] < line.quantity:
raise InsufficientStockError(
line.product_id, line.quantity, _inventory[line.product_id]
)
return {"status": "confirmed"}
Overriding Default Error Handlers
Override the 422 validation error handler to change its format:
from fastapi import FastAPI, Request, status
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
) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "VALIDATION_ERROR",
"detail": exc.errors(),
"body": exc.body,
},
)
Override the 500 internal server error handler:
import logging
logger = logging.getLogger(__name__)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled error on %s", request.url)
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": "An unexpected error occurred"},
)
Never expose Python tracebacks or exception messages to API clients in production. Log them server-side and return a generic error message.
Consistent Error Envelope
Define a standard error response model across the entire API:
from pydantic import BaseModel
class ErrorResponse(BaseModel):
error: str # Machine-readable code: "ITEM_NOT_FOUND"
message: str # Human-readable description
detail: dict | list | None = None # Optional additional info
HTTP Exception with Headers
Some errors require additional headers (e.g., WWW-Authenticate for 401):
from fastapi import HTTPException
def require_auth_header():
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
| 500 returned instead of 404 | Unhandled KeyError instead of HTTPException | Always raise HTTPException for expected error conditions |
| Stack trace visible to clients | No global exception handler | Add @app.exception_handler(Exception) |
exception_handler not firing | Handler registered after include_router | Register handlers before including routers |
| 422 format unexpected by client | Default FastAPI 422 format | Override RequestValidationError handler to normalize |
| Custom exception handler ignores subclasses | Python exception hierarchy not traversed | Register handlers for each specific exception type |
Hands-On Practice
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
class ResourceNotFound(Exception):
def __init__(self, resource: str, id: int):
self.resource = resource
self.id = id
@app.exception_handler(ResourceNotFound)
async def not_found_handler(request: Request, exc: ResourceNotFound) -> JSONResponse:
return JSONResponse(
status_code=404,
content={"error": "NOT_FOUND", "resource": exc.resource, "id": exc.id},
)
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(
status_code=422,
content={"error": "VALIDATION_FAILED", "fields": exc.errors()},
)
@app.get("/items/{item_id}")
async def get_item(item_id: int) -> dict:
if item_id != 1:
raise ResourceNotFound("item", item_id)
return {"id": 1, "name": "Demo"}
@app.get("/trigger-500")
async def trigger_error() -> dict:
raise RuntimeError("Something broke!")
uvicorn app.main:app --reload
curl http://localhost:8000/items/99 -w "\nStatus: %{http_code}"
# → {"error": "NOT_FOUND", "resource": "item", "id": 99}
curl "http://localhost:8000/items/abc" -w "\nStatus: %{http_code}"
# → {"error": "VALIDATION_FAILED", ...}