Error Handling — APIRouter Exception Isolation
APIRouters are like self-contained mini-applications for organizing routes, but for global concerns like exception handling, the FastAPI instance acts as the ultimate authority. Exception handlers registered on a router are effective only if that router is the root, or if specific handler propagation rules are configured.
The Setup
You are organizing a large service using APIRouter to isolate routes logically. You want to register a custom exception and handler to format database error outputs cleanly, declaring the handler directly on the router.
What Does This Print?
from fastapi import FastAPI, APIRouter
from fastapi.responses import JSONResponse
class DBConnectionError(Exception):
pass
router = APIRouter()
# Registering exception handler on router level
@router.exception_handler(DBConnectionError)
async def db_error_handler(request, exc):
return JSONResponse(status_code=503, content={"detail": "Database offline"})
@router.get("/data")
async def get_data():
raise DBConnectionError("Lost link to primary database")
app = FastAPI()
app.include_router(router)
/data endpoint.
The Output
The server logs a standard traceback and returns a generic 500 Internal Server Error to the client instead of the configured JSON payload with a 503 status:
The registered custom exception handler db_error_handler was ignored during exception dispatching.
Why Python Does This
FastAPI and Starlette manage exception handling via an ExceptionMiddleware component wrapped at the core application level. During route execution, exceptions bubble out of router path operations completely. When search matches are executed, the middleware queries exceptions registered on the central app instance. Starlette's APIRouter implements decorator methods like @router.exception_handler to preserve syntax uniformity, but it does not hook into Starlette's central exception middleware registry unless explicitly registered on the root FastAPI instance.
The Fix
from fastapi import FastAPI, APIRouter
from fastapi.responses import JSONResponse
class DBConnectionError(Exception):
pass
router = APIRouter()
@router.get("/data")
async def get_data():
raise DBConnectionError("Lost link to primary database")
app = FastAPI()
app.include_router(router)
# Always register exception handlers directly on the primary FastAPI application object
@app.exception_handler(DBConnectionError)
async def db_error_handler(request, exc):
return JSONResponse(status_code=503, content={"detail": "Database offline"})
Registering the db_error_handler directly on the app (FastAPI instance) ensures that it becomes a global exception handler. When DBConnectionError is raised anywhere in the application, including within routes defined in included routers, the root app instance's handler will intercept and process it, providing the desired custom response.
How This Fails in Real Systems
A payments microservice isolated credit card parsing on a payment router. During maintenance, a dynamic validation failure raised card errors that bypasses custom APIRouter handlers. Instead of clean 'Card Invalid' messages, clients received generic HTTP 500 errors, leading to payment retries.
Key Takeaway
APIRouter to catch exceptions raised by routes within that same router when it's included in the main FastAPI application.