Request Lifecycle: How Web Frameworks Handle a Request · Python Frameworks: Django, Flask and FastAPI

FastAPI and Starlette: Middleware, Exceptions and Dependencies

Why an auth middleware that raised HTTPException(401) returned 500 errors, and how Starlette's middleware stack, exception handling and FastAPI dependencies are layered.

Scenario

Example app: a FastAPI service adds an HTTP middleware that checks an API key header and raises HTTPException(status_code=401) when it's missing. The team registered a custom exception handler that formats errors as JSON problem details.

What you see in production

  • Clients without an API key get 500 Internal Server Error instead of 401.
  • The custom JSON error format never appears for these responses.
  • Error-rate alerts fire for what are really client mistakes.

The naive code

@app.middleware("http")
async def require_api_key(request: Request, call_next):
    if request.headers.get("x-api-key") != API_KEY:
        raise HTTPException(status_code=401, detail="missing API key")   # raised OUTSIDE the exception layer
    return await call_next(request)

Reproduce it

Starlette wraps the app in layers: an outer server-error layer, your middleware, an inner exception layer that applies your exception handlers, then the router, dependencies and endpoint. The simulation sends a request without a key through both versions.

Where an HTTPException is raised decides who handles itTry it yourself
class HTTPException(Exception):
    def __init__(self, status, detail):
        self.status, self.detail = status, detail

def server_errors(app):          # outermost: turns any unhandled exception into a 500
    def layer(req):
        try:
            return app(req)
        except Exception:
            return "500 Internal Server Error"
    return layer

def exception_handlers(app):     # inner: applies registered handlers (e.g. HTTPException -> JSON)
    def layer(req):
        try:
            return app(req)
        except HTTPException as e:
            return f'{e.status} {{"detail": "{e.detail}"}}'
    return layer

def middleware_raises(app):
    def layer(req):
        if req.get("x-api-key") != "secret":
            raise HTTPException(401, "missing API key")
        return app(req)
    return layer

def middleware_returns(app):
    def layer(req):
        if req.get("x-api-key") != "secret":
            return '401 {"detail": "missing API key"}'   # return a response instead of raising
        return app(req)
    return layer

def dependency_auth(req):        # the FastAPI way: auth as a dependency, inside the exception layer
    if req.get("x-api-key") != "secret":
        raise HTTPException(401, "missing API key")
    return "client-42"

def endpoint(req):
    return f"200 hello {dependency_auth(req)}"

stacks = {
    "middleware raises HTTPException": server_errors(middleware_raises(exception_handlers(endpoint))),
    "middleware returns a response": server_errors(middleware_returns(exception_handlers(endpoint))),
    "auth in a dependency": server_errors(exception_handlers(endpoint)),
}
for label, app in stacks.items():
    print(f"{label:<34} no key -> {app({})} | with key -> {app({'x-api-key': 'secret'})}")

Output

middleware raises HTTPException    no key -> 500 Internal Server Error | with key -> 200 hello client-42
middleware returns a response      no key -> 401 {"detail": "missing API key"} | with key -> 200 hello client-42
auth in a dependency               no key -> 401 {"detail": "missing API key"} | with key -> 200 hello client-42

Root cause

User middleware sits outside the layer that applies exception handlers. An HTTPException raised in middleware therefore isn't converted to a 401 by your handlers; it bubbles up to the outermost error layer, which returns a generic 500.

The lifecycle, step by step

  1. The ASGI server (Uvicorn, Hypercorn) calls the app with the connection scope.
  2. Server error middleware (outermost) catches anything unhandled and returns 500.
  3. User middleware, in reverse order of adding: the last added runs first (outermost). See FastAPI middleware and Starlette middleware.
  4. Exception middleware applies your @app.exception_handler(...) handlers.
  5. The router matches the path; FastAPI resolves dependencies (each dependency runs once per request by default, even if several parameters use it), validates path, query and body parameters with Pydantic, and returns 422 on validation errors.
  6. The endpoint runs (async def on the event loop, def in a thread pool), and the return value is serialised with the response model.
  7. Dependencies that use yield run their exit code (closing sessions); see dependencies with yield for exactly when this happens in your FastAPI version. Background tasks run after the response is sent.

The fix

  • In middleware, return a response (JSONResponse(status_code=401, ...)) instead of raising HTTPException.
  • Better: implement authentication and authorisation as dependencies (Depends(get_current_user), or router-level dependencies=[...]). They run inside the exception layer, show up in OpenAPI docs, and are easy to override in tests.
  • Keep middleware for truly global, protocol-level concerns: CORS, GZip, trusted hosts, request IDs, timing.

Trade-offs

  • Dependencies run only for matched routes; requests to unknown paths never reach them (often desirable).
  • Some middleware styles (BaseHTTPMiddleware, the @app.middleware("http") decorator) have overhead and limitations with streaming; pure ASGI middleware is faster but harder to write.

Detect it in production

  • Watch for 500s whose logs show an HTTPException: it was raised in the wrong layer.
  • Test error paths (missing auth, invalid body) and assert the exact status code and body format.

Key points

  • Starlette layers: server errors (outermost) → your middleware (last added = outermost) → exception handlers → router → dependencies → endpoint.
  • HTTPException raised in middleware becomes a 500; return a response or use a dependency.
  • Put auth in dependencies; keep middleware for global concerns.

Practice questions

In what order does FastAPI/Starlette process a request?

Answer

ASGI server → ServerErrorMiddleware (outermost) → user middleware (last added runs first) → ExceptionMiddleware (custom exception handlers) → router → dependency resolution and parameter validation → endpoint → response serialisation; dependency exit code and background tasks run afterwards.

Why does raising HTTPException in a FastAPI middleware return a 500?

Answer

User middleware sits outside the exception-handling layer, so the exception isn't converted by the HTTPException handler and reaches the outer server-error middleware, which returns 500. Return a response from middleware instead, or use a dependency.

Why are dependencies usually a better place for authentication in FastAPI than middleware?

Answer

They run inside the exception layer (HTTPException works), only for matched routes, appear in the OpenAPI schema, compose with other dependencies, and can be overridden in tests with app.dependency_overrides.

Open in the app to mark lessons complete, take notes and track your progress.