Skip to main content

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.

Learning Focus

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

app/routers/items.py
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:

app/exceptions.py
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
app/main.py
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,
},
)
app/routers/orders.py
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:

app/main.py
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:

app/main.py
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"},
)
danger

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:

app/models/errors.py
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):

app/routers/auth.py
from fastapi import HTTPException

def require_auth_header():
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)

Common Pitfalls

PitfallCause / SymptomFix
500 returned instead of 404Unhandled KeyError instead of HTTPExceptionAlways raise HTTPException for expected error conditions
Stack trace visible to clientsNo global exception handlerAdd @app.exception_handler(Exception)
exception_handler not firingHandler registered after include_routerRegister handlers before including routers
422 format unexpected by clientDefault FastAPI 422 formatOverride RequestValidationError handler to normalize
Custom exception handler ignores subclassesPython exception hierarchy not traversedRegister handlers for each specific exception type

Hands-On Practice

app/main.py
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!")
test-errors.sh
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", ...}

What's Next