Debugging middleware order, streaming, 500s without CORS headers or performance means knowing what actually runs per request.
ASGI is the interface between servers and Python async apps. An ASGI app is just async def app(scope, receive, send). Uvicorn parses the HTTP bytes and calls it once per connection or request:
scopeis a dict describing the connection:type("http","websocket"or"lifespan"),method,path,query_string, andheadersas a list of(bytes, bytes)pairs.await receive()returns events such as{"type": "http.request", "body": b"...", "more_body": False}; bodies may arrive in chunks.await send(...)emitshttp.response.start(status and headers) and then one or morehttp.response.bodymessages, which is how streaming works.
FastAPI subclasses Starlette's application. On the first request it builds the middleware stack, outermost first: ServerErrorMiddleware (turns uncaught exceptions into 500s and runs your Exception handler), your middleware in reverse order of adding, ExceptionMiddleware (HTTPException and your other handlers), FastAPI's exit-stack middleware, then the router. The router tries routes in order; the matching APIRoute reads the body, solves dependencies, calls your function and serializes the result.
That layout explains real surprises:
- A 500 is produced outside your middleware, so it carries no CORS headers and the browser reports a CORS error instead of the crash.
@app.middleware("http")is built onBaseHTTPMiddleware, which wraps each request in extra tasks and streams; it's convenient but adds overhead. Hot-path middleware is better written as pure ASGI, as in the example, wrappingsendto edit headers.- Anything ASGI can be mounted:
app.mount("/static", StaticFiles(directory="static")), another FastAPI app, or a WSGI app via an adapter.
Starlette's Request is a thin, lazy view over scope and receive.
Example
import time
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.types import ASGIApp, Message, Receive, Scope, Send
class TimingMiddleware: # pure ASGI: no BaseHTTPMiddleware
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http": # pass websocket/lifespan through
return await self.app(scope, receive, send)
start = time.perf_counter()
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
ms = f"{(time.perf_counter() - start) * 1000:.1f}"
message["headers"] = [*message["headers"], (b"server-timing", f"app;dur={ms}".encode())]
await send(message)
await self.app(scope, receive, send_wrapper)
app = FastAPI()
app.add_middleware(TimingMiddleware)
@app.get("/")
def root():
return {"ok": True}
print(TestClient(app).get("/").headers["server-timing"]) # app;dur=0.9 (varies)Remember
- An ASGI app is async (scope, receive, send); responses are sent as messages
- Stack: ServerErrorMiddleware > your middleware > ExceptionMiddleware > router
- 500 responses bypass user middleware, so they lack CORS and custom headers
- Pure ASGI middleware avoids BaseHTTPMiddleware's overhead
Watch out for
- Reading the request body in BaseHTTPMiddleware and expecting streaming endpoints to behave normally.