Advanced Examples
Examples 39-78 assemble the beginner and intermediate primitives into production-shaped services and then go
deeper: a full typed async CRUD service and pagination/filtering (39-40), auth, rate limiting, pooled async
HTTP clients, timeout/retry, a lifespan-managed background worker, WebSockets, CPU offloading, an
OpenAPI-driven client, an integration test suite, the ruff/pyright gate, graceful shutdown, observability,
a remotebrowser-shaped fan-out, and the capstone preview (41-54); then Pydantic v2 deep features (55-58),
APIRouter modularity and DI/lifespan patterns (59-62), constrained params and documented responses (63-64),
centralized error handling and middleware variants (65-69), OAuth2 and uploads (70-71), backpressure and
broadcast rooms (72-73), resiliency patterns (74-76), and uv/Docker deployment (77-78). Every FastAPI
example is a complete, self-contained module under learning/code/, served with uvicorn app:app --port 8000;
the pure-library examples run with python3 example.py.
Example 39: A Full Typed Async CRUD Service
ex-39 · exercises co-10 to co-17
A complete typed async CRUD over aiosqlite: dependency-injected sessions, Pydantic validation, a precise
404 on a missing id, and parameterized queries throughout. No SQL lives outside the repository-shaped query
calls.
learning/code/ex-39-full-crud-service/app.py
"""Example 39: A Full Typed Async CRUD Service.
A complete typed async CRUD over aiosqlite: dependency-injected sessions, Pydantic validation, a 404 on a
missing id, and parameterized queries throughout -- no SQL in any handler beyond the repository layer.
Run: uvicorn app:app --port 8000, then exercise POST/GET/PUT/DELETE on /tasks. (co-10 to co-17)
"""
from collections.abc import AsyncIterator
import aiosqlite # => the async driver (co-16)
from fastapi import Depends, FastAPI, HTTPException # => DI + error mapping (co-15, co-17)
from pydantic import BaseModel # => Pydantic models (co-12)
app = FastAPI() # => the ASGI application uvicorn serves
DB_PATH = "tasks.db" # => a file DB so rows survive across requests
class TaskIn(BaseModel): # => the create/replace body shape (co-12)
title: str # => required
done: bool = False # => optional with a default
class Task(BaseModel): # => the response shape (co-14)
id: int
title: str
done: bool
async def get_session() -> AsyncIterator[aiosqlite.Connection]: # => one async session per request (co-15)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, done INTEGER)") # => idempotent schema (co-16)
await db.commit()
yield db
def _row_to_task(row: aiosqlite.Row | tuple) -> Task: # => the ONE place a raw row becomes a typed Task (co-14)
return Task(id=int(row[0]), title=str(row[1]), done=bool(row[2])) # => narrowed, typed fields
@app.post("/tasks", response_model=Task, status_code=201) # => create (co-17)
async def create_task(task: TaskIn, session: aiosqlite.Connection = Depends(get_session)) -> Task:
cursor = await session.execute("INSERT INTO tasks (title, done) VALUES (?, ?)", (task.title, int(task.done))) # => parameterized (co-16)
await session.commit()
return Task(id=int(cursor.lastrowid), title=task.title, done=task.done) # => the new row
@app.get("/tasks/{task_id}", response_model=Task) # => read one (co-17)
async def read_task(task_id: int, session: aiosqlite.Connection = Depends(get_session)) -> Task:
cursor = await session.execute("SELECT id, title, done FROM tasks WHERE id = ?", (task_id,)) # => parameterized
row = await cursor.fetchone()
if row is None: # => missing -> precise 404 (co-17)
raise HTTPException(status_code=404, detail="task not found")
return _row_to_task(row) # => the persisted row
@app.put("/tasks/{task_id}", response_model=Task) # => replace (co-17)
async def update_task(task_id: int, task: TaskIn, session: aiosqlite.Connection = Depends(get_session)) -> Task:
cursor = await session.execute("UPDATE tasks SET title = ?, done = ? WHERE id = ?", (task.title, int(task.done), task_id)) # => parameterized
await session.commit()
if cursor.rowcount == 0: # => no row matched -> 404
raise HTTPException(status_code=404, detail="task not found")
row = await (await session.execute("SELECT id, title, done FROM tasks WHERE id = ?", (task_id,))).fetchone()
assert row is not None # => rowcount > 0 guarantees this -- narrows for the type checker
return _row_to_task(row)
@app.delete("/tasks/{task_id}", status_code=204) # => delete (co-17)
async def delete_task(task_id: int, session: aiosqlite.Connection = Depends(get_session)) -> None:
cursor = await session.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) # => parameterized
await session.commit()
if cursor.rowcount == 0: # => nothing deleted -> 404
raise HTTPException(status_code=404, detail="task not found")Run: uvicorn app:app --port 8000, then exercise POST, GET, PUT, DELETE on /tasks.
Output (a create-then-read round trip):
{"id":1,"title":"write","done":false}
{"id":1,"title":"write","done":false}Key takeaway: a full typed async CRUD service is the intermediate primitives (DI, async DB, validation, error mapping) assembled into one resource lifecycle, with every query parameterized and every failure mapped to a precise status.
Why it matters: this is the shape the capstone generalizes and the shape a real service grows from. The discipline that makes it safe at scale -- parameterized queries, a repository-shaped session, a 404 on absence -- is exactly what keeps the same code correct under concurrent load and swappable to a different database by changing only the driver.
Example 40: Pagination and Filtering on a List Endpoint
ex-40 · exercises co-11, co-16
GET /tasks composes limit/offset pagination with an optional done filter in one parameterized query,
returning a page plus total/next metadata where total reflects the filtered count.
learning/code/ex-40-pagination-and-filtering/app.py
"""Example 40: Pagination and Filtering on a List Endpoint.
GET /tasks composes limit/offset pagination with an optional status filter in one parameterized query,
returning items plus total/next metadata. Run: uvicorn app:app --port 8000. (co-11, co-16)
"""
from collections.abc import AsyncIterator
import aiosqlite # => the async driver (co-16)
from fastapi import Depends, FastAPI, Query # => Query declares bounded params (co-11)
from pydantic import BaseModel # => Pydantic models (co-12)
app = FastAPI() # => the ASGI application uvicorn serves
DB_PATH = "tasks.db"
class Task(BaseModel): # => a row shape
id: int
title: str
done: bool
class Page(BaseModel): # => the paginated envelope (co-14)
items: list[Task] # => one page of rows
total: int # => the FILTERED count, not the whole table
next: int | None # => the next offset, or None at the end
async def get_session() -> AsyncIterator[aiosqlite.Connection]: # => one session per request (co-15)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, done INTEGER)")
await db.commit()
yield db
@app.get("/tasks", response_model=Page) # => the list endpoint
async def list_tasks( # => pagination + filter composed in one query-shaping handler (co-11, co-16)
done: bool | None = Query(default=None), # => an OPTIONAL filter -- absent means "all"
limit: int = Query(default=10, ge=1, le=50), # => bounded limit (co-11): 1..50, default 10
offset: int = Query(default=0, ge=0), # => non-negative offset, default 0
session: aiosqlite.Connection = Depends(get_session),
) -> Page:
where = " WHERE done = ?" if done is not None else "" # => an optional filter clause
params: list[object] = [int(done)] if done is not None else [] # => filter value, parameterized (co-16)
total_row = await (await session.execute(f"SELECT COUNT(*) FROM tasks{where}", params)).fetchone() # => filtered count
assert total_row is not None # => COUNT(*) always returns one row
total = int(total_row[0]) # => the filtered total (co-11)
rows = await (await session.execute(f"SELECT id, title, done FROM tasks{where} ORDER BY id LIMIT ? OFFSET ?", [*params, limit, offset])).fetchall() # => still parameterized (co-16)
items = [Task(id=int(r[0]), title=str(r[1]), done=bool(r[2])) for r in rows] # => typed rows (co-14)
next_offset = offset + limit # => the next page's offset
return Page(items=items, total=total, next=next_offset if next_offset < total else None) # => None at endRun: uvicorn app:app --port 8000, then curl 'localhost:8000/tasks?done=true&limit=5'.
Output:
{"items":[...],"total":3,"next":null}Key takeaway: pagination and filtering compose in one parameterized query; total must reflect the
filtered count, and next is None once the last page is reached.
Why it matters: an unbounded list endpoint is a denial-of-service risk -- a table with ten rows today can
have ten million next year. A bounded limit with a sane default and a hard maximum is the guard that keeps
that growth from becoming an outage, and composing it with a filter in one query keeps the work on the database
instead of in the handler.
Example 41: An Auth Dependency Gates Protected Routes
ex-41 · exercises co-15, co-17
A Depends that reads the Authorization header, validates a bearer token, and raises 401 otherwise. One
declared parameter gates a protected route; an open route simply omits it.
learning/code/ex-41-auth-dependency/app.py
"""Example 41: An Auth Dependency Gates Protected Routes.
A Depends that reads the Authorization header, validates a bearer token, and raises 401 otherwise -- gating
protected routes with one declared parameter. Run: uvicorn app:app --port 8000, then:
curl -i localhost:8000/me and curl -H 'Authorization: Bearer secret' localhost:8000/me (co-15, co-17)
"""
from fastapi import Depends, FastAPI, Header, HTTPException # => Header reads request headers (co-15, co-17)
app = FastAPI() # => the ASGI application uvicorn serves
VALID_TOKEN = "secret" # => a stand-in for a real signed/opaque token
def require_token(authorization: str | None = Header(default=None)) -> str: # => a reusable auth dependency (co-15)
# => Header(default=None) makes Authorization OPTIONAL so we can reject a missing one ourselves (co-17)
if authorization != f"Bearer {VALID_TOKEN}": # => exact match required (co-17)
raise HTTPException(status_code=401, detail="invalid or missing token") # => 401 before the handler runs
return "caller" # => a resolved caller identity handed to the handler
@app.get("/me") # => a PROTECTED route -- declares the auth dependency
def me(caller: str = Depends(require_token)) -> dict[str, str]: # => the dependency gates this route (co-15)
return {"caller": caller} # => only reachable with a valid token (co-14)
@app.get("/public") # => an OPEN route -- declares no auth dependency
def public() -> dict[str, str]: # => no token required
return {"msg": "open"} # => reachable by anyone (co-14)Run: uvicorn app:app --port 8000, then curl -i localhost:8000/me (401) and curl -H 'Authorization: Bearer secret' localhost:8000/me (200).
Key takeaway: a reusable auth dependency gates any route that declares it; the open/protected split is just which routes include the parameter.
Why it matters: centralizing the auth check in one dependency means the rule is enforced identically
everywhere it applies, and adding a new protected route is one extra parameter rather than copy-pasted auth
logic. Example 70 generalizes this to the OAuth2 password flow with a /docs "Authorize" button.
Example 42: An In Process Rate Limit Middleware
ex-42 · exercises co-18, co-23
A simple in-process rate limiter as middleware: a lock-guarded per-client counter allows N requests per window
and rejects the rest with 429. The lock is what keeps the counter correct under concurrent requests.
learning/code/ex-42-rate-limit-middleware/app.py
"""Example 42: An In Process Rate Limit Middleware.
A simple in-process rate limiter as middleware: a lock-guarded per-client counter allows N requests per window,
rejecting the rest with 429. Run: uvicorn app:app --port 8000, then hit / many times fast. (co-18, co-23)
"""
import asyncio # => asyncio.Lock guards the shared limiter state (co-23)
import time # => wall-clock for the window
from collections.abc import Awaitable, Callable # => the typed shape of call_next (co-18)
from fastapi import FastAPI, Request # => Request identifies the caller (co-18)
from fastapi.responses import JSONResponse, Response # => the 429 response
app = FastAPI() # => the ASGI application uvicorn serves
MAX_HITS = 3 # => allow this many hits per window
WINDOW = 1.0 # => the window length in seconds
_hits: dict[str, list[float]] = {} # => caller -> timestamps within the current window (co-23)
_lock = asyncio.Lock() # => guards _hits across concurrent requests (co-23)
@app.middleware("http") # => wraps every request (co-18)
async def rate_limit( # => call_next runs the downstream handler
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
client = request.client.host if request.client else "unknown" # => identify the caller
now = time.monotonic() # => a monotonic clock for window math
async with _lock: # => the critical section -- no lost updates under concurrency (co-23)
recent = [t for t in _hits.get(client, []) if now - t < WINDOW] # => drop timestamps outside the window
if len(recent) >= MAX_HITS: # => over the limit for this window
return JSONResponse(status_code=429, content={"error": "rate limited"}) # => 429 Too Many Requests
recent.append(now) # => record this hit
_hits[client] = recent # => store the updated list
return await call_next(request) # => under the limit -- proceed to the handler
@app.get("/") # => a route that is rate-limited by the middleware above
def read_root() -> dict[str, str]: # => minimal handler
return {"msg": "ok"} # => only the first MAX_HITS calls per window return 200Run: uvicorn app:app --port 8000, then hit / more than MAX_HITS times within one second.
Output (the over-limit response):
HTTP/1.1 429 Too Many Requests
{"error":"rate limited"}Key takeaway: an in-process rate limiter is middleware plus a lock-guarded per-client counter; the lock is what prevents lost updates when concurrent requests read the counter simultaneously.
Why it matters: a rate limiter is the front-line defense against an abusive or buggy client overwhelming a service. The lock from Example 38 reappears here because the read-check-write on the counter is exactly the race that loses updates without it. (A multi-process deployment would move this counter to Redis -- the same logic, a shared store.)
Example 43: An Async HTTP Client Service with a Pool
ex-43 · exercises co-16, co-18
A pooled httpx.AsyncClient opened once in a lifespan and reused across requests, so connection pooling
actually pays off instead of a fresh client (and TCP handshake) per call.
learning/code/ex-43-async-http-client-service/app.py
"""Example 43: An Async HTTP Client Service with a Pool.
A pooled async httpx client opened ONCE in a lifespan and reused across requests, so connection pooling
actually pays off instead of a fresh client per call. Run: uvicorn app:app --port 8000. (co-16, co-18)
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager # => turns an async gen into a lifespan (co-18)
import httpx # => the async HTTP client (co-16)
from fastapi import FastAPI, Request # => Request reads app.state (co-18)
@asynccontextmanager # => lifespan factory
async def lifespan(app: FastAPI) -> AsyncIterator[None]: # => open once at startup, close at shutdown (co-18)
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client: # => a POOLED client reused by every request
app.state.client = client # => stash it on app.state (co-18)
yield # => the app serves many requests sharing the one client
# => the async with closes the client (and its pool) on shutdown (co-18)
app = FastAPI(lifespan=lifespan) # => register the lifespan (co-18)
@app.get("/fetch") # => a route that calls an upstream using the shared client
async def fetch(request: Request) -> dict[str, str]: # => reads the shared client
client: httpx.AsyncClient = request.app.state.client # => the pooled client -- reused, not created per call (co-18)
try:
response = await client.get("https://example.com") # => an async upstream call (co-16)
return {"status": "ok", "upstream_status": str(response.status_code)} # => the upstream's status (co-14)
except httpx.HTTPError as exc: # => a typed upstream failure
return {"status": "error", "reason": str(exc)} # => a structured error, never a 500 traceback (co-17)Run: uvicorn app:app --port 8000, then curl localhost:8000/fetch.
Output:
{"status":"ok","upstream_status":"200"}Key takeaway: a pooled async client belongs in a lifespan, shared on app.state -- creating one per
request throws away pooling and pays a fresh handshake every call.
Why it matters: an async service that fans out to upstreams (Example 28, Example 53) reuses connections through a pooled client; opening a new client per request is both slower and more resource-hungry. The lifespan is the one correct place to open and close that pool.
Example 44: Timeout and Bounded Retry on an Upstream
ex-44 · exercises co-17, co-06
An upstream call wrapped in asyncio.timeout plus a bounded retry. A slow call is cut off after the
deadline; a transient failure is retried a fixed number of times; exhaustion yields a precise 503.
learning/code/ex-44-timeout-and-retry/app.py
"""Example 44: Timeout and Bounded Retry on an Upstream.
An upstream call wrapped in a timeout plus a BOUNDED retry -- a slow or failing upstream is cut off after a
fixed deadline, not allowed to stall the loop indefinitely. Run: uvicorn app:app --port 8000. (co-17, co-06)
"""
import asyncio # => asyncio.timeout enforces a deadline (co-06)
from fastapi import FastAPI, HTTPException # => HTTPException maps a failure (co-17)
app = FastAPI() # => the ASGI application uvicorn serves
MAX_ATTEMPTS = 3 # => a BOUNDED retry -- never an unbounded loop (co-06)
TIMEOUT = 0.10 # => the per-attempt deadline in seconds
async def flaky_upstream(attempt: int) -> str: # => a stand-in that fails until the 3rd attempt
await asyncio.sleep(0.05) # => simulates network latency (co-02)
if attempt < 2: # => fail the first two attempts
raise RuntimeError("upstream unavailable") # => a transient failure
return "upstream-data" # => succeeds on the third attempt
@app.get("/fetch") # => a route that calls the flaky upstream with a timeout + retry
async def fetch() -> dict[str, str]: # => bounded retry loop
last_error: str = "" # => remembers the most recent failure for the final 503
for attempt in range(MAX_ATTEMPTS): # => a BOUNDED number of attempts (co-06)
try:
async with asyncio.timeout(TIMEOUT): # => cut off a slow call after TIMEOUT seconds (co-06)
return {"data": await flaky_upstream(attempt)} # => success -- return immediately
except (RuntimeError, TimeoutError) as exc: # => a transient failure or a timeout -> retry
last_error = str(exc) # => record and try again
raise HTTPException(status_code=503, detail=f"upstream failed: {last_error}") # => exhausted retries -> 503 (co-17)Run: uvicorn app:app --port 8000, then curl localhost:8000/fetch.
Output:
{"data":"upstream-data"}Key takeaway: every upstream call needs a deadline (asyncio.timeout) and, where transient failures are
expected, a bounded retry -- never an unbounded loop, and exhaustion maps to a precise 503.
Why it matters: an upstream without a timeout can stall the loop indefinitely (co-06), and an unbounded retry can amplify a failure into a flood. A bounded retry with a per-attempt deadline degrades gracefully: it either succeeds quickly, retries a fixed number of times, or fails fast with a precise status.
Example 45: A Lifespan Managed Background Worker
ex-45 · exercises co-19, co-18
A background task launched at startup drains an asyncio.Queue, processing items without blocking requests.
Requests enqueue; the worker consumes; shutdown cancels the worker cleanly.
learning/code/ex-45-background-worker-pattern/app.py
"""Example 45: A Lifespan Managed Background Worker.
A background task launched in a lifespan handler drains an asyncio.Queue, processing items without blocking
requests. The worker is started at startup and cancelled cleanly at shutdown. Run: uvicorn app:app --port 8000.
(co-19, co-18)
"""
import asyncio # => asyncio.Queue + create_task (co-19)
from fastapi import FastAPI, Request # => Request reads app.state (co-18)
app = FastAPI() # => the ASGI application uvicorn serves
async def worker(queue: asyncio.Queue[str]) -> None: # => a long-running consumer loop
while True: # => runs until cancelled at shutdown
item = await queue.get() # => blocks (cooperatively) until an item arrives (co-19)
try:
await asyncio.sleep(0.01) # => simulate processing the item (co-02)
finally:
queue.task_done() # => mark the item processed even if processing raised (co-19)
@app.on_event("startup") # => LEGACY startup hook -- start the worker once (co-18; see note below)
async def start_worker() -> None:
queue: asyncio.Queue[str] = asyncio.Queue() # => the shared queue requests push to
task = asyncio.create_task(worker(queue)) # => run the worker concurrently with the server (co-19)
app.state.queue = queue # => stash the queue so handlers can enqueue
app.state.worker = task # => stash the task so shutdown can cancel it
@app.on_event("shutdown") # => LEGACY shutdown hook -- stop the worker cleanly (co-18)
async def stop_worker() -> None:
task: asyncio.Task[None] = app.state.worker # => the task started at startup
task.cancel() # => cancel the infinite loop (co-19)
try:
await task # => await cancellation so it actually stops
except asyncio.CancelledError: # => the expected outcome of cancel()
pass
@app.post("/enqueue") # => a route that hands work to the background worker
async def enqueue(request: Request) -> dict[str, int]: # => reads the shared queue
queue: asyncio.Queue[str] = request.app.state.queue # => the lifespan-created queue
await queue.put("job") # => the worker picks this up without blocking the response (co-19)
return {"queued": queue.qsize()} # => how many items are pending (co-14)Run: uvicorn app:app --port 8000, then curl localhost:8000/enqueue.
Output:
{"queued":1}Key takeaway: a lifespan-managed worker is the heavier alternative to BackgroundTasks -- a long-running
consumer loop draining a queue, started at startup and cancelled at shutdown.
Why it matters: BackgroundTasks (Example 27) runs a one-shot side effect; a worker runs continuously,
which is what a job processor, a metrics flusher, or a notification fan-out needs. Owning its lifecycle in the
lifespan is what guarantees it starts once, runs alongside the server, and stops cleanly.
Note: this example uses the
@app.on_eventstartup/shutdown hooks for clarity; the modern, preferred equivalent is thelifespancontext manager (Example 25). Both achieve the same lifecycle.
Example 46: A WebSocket Echo Endpoint
ex-46 · exercises co-22, co-05
A FastAPI WebSocket route echoes each inbound message back -- a bidirectional, long-lived connection distinct from the request/response cycle. The connection stays open across many messages until the client disconnects.
learning/code/ex-46-websocket-endpoint/app.py
"""Example 46: A WebSocket Echo Endpoint.
A FastAPI WebSocket route echoes each inbound message back -- a bidirectional, long-lived connection distinct
from the request/response cycle. Run: uvicorn app:app --port 8000, then connect a ws client. (co-22, co-05)
"""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect # => WebSocket is the bidirectional verb (co-22)
app = FastAPI() # => the ASGI application uvicorn serves
@app.websocket("/ws") # => a WebSocket route -- a long-lived bidirectional connection (co-22)
async def echo(websocket: WebSocket) -> None: # => the connection object
await websocket.accept() # => complete the WS handshake before reading/sending (co-22)
try:
while True: # => the connection stays open across many messages (co-05)
message = await websocket.receive_text() # => await the next inbound message (co-05, co-22)
await websocket.send_text(f"echo: {message}") # => echo it straight back
except WebSocketDisconnect: # => the client closed the connection
return # => stop the loop cleanly -- the connection is gone (co-22)Run: uvicorn app:app --port 8000, then connect a WebSocket client to ws://localhost:8000/ws.
Output (inbound hello -> outbound echo: hello):
echo: helloKey takeaway: a WebSocket route is a long-lived bidirectional connection; receive_*/send_* await each
direction, and WebSocketDisconnect ends the loop cleanly.
Why it matters: WebSockets are the tool when the client needs server-pushed updates with low latency in both directions (a chat, a collaborative editor, live controls). Example 73 builds on this to broadcast to many connections at once.
Example 47: Offloading CPU Bound Work to a Process Pool
ex-47 · exercises co-03, co-06
A genuinely CPU-bound function offloaded to a ProcessPoolExecutor so it runs in a separate process, in
parallel -- the event loop stays responsive, and the main process's GIL is not held. This is the contrast with
Example 8's thread executor: threads help I/O, processes help CPU.
learning/code/ex-47-executor-for-cpu-work/example.py
"""Example 47: Offloading CPU Bound Work to a Process Pool.
A genuinely CPU-bound function offloaded to a ProcessPoolExecutor so it runs in a SEPARATE process, in parallel
-- the event loop stays responsive, and the GIL of the main process is not held. Run: python3 example.py. (co-03, co-06)
"""
import asyncio # => the event loop (co-02)
from concurrent.futures import ProcessPoolExecutor # => true parallelism for CPU work (co-03)
def cpu_heavy(n: int) -> int: # => a CPU-bound function -- GIL-bound if run on the loop's thread
total = 0 # => accumulator
for i in range(n): # => a tight CPU loop
total += i * i % 7 # => meaningless arithmetic that burns CPU
return total # => the result
async def main() -> int: # => offloads the CPU work so the loop never stalls
loop = asyncio.get_running_loop() # => the running loop
# => a ProcessPoolExecutor runs the function in SEPARATE processes -- real parallelism for CPU work (co-03)
with ProcessPoolExecutor() as pool: # => a pool of worker processes
# => run_in_executor(offloader) keeps the loop responsive while the CPU work proceeds elsewhere (co-06)
result = await loop.run_in_executor(pool, cpu_heavy, 1_000_000) # => resolves to the int
return result # => the CPU-bound result, with the loop never blocked
if __name__ == "__main__": # => only runs when executed directly
out = asyncio.run(main()) # => drive the async main
print(out) # => Output: the computed total (loop stayed responsive throughout)Run: python3 example.py
Output:
4285714285714Key takeaway: CPU-bound work needs a process pool (real parallelism, separate GILs), not a thread pool and
not the event loop -- async buys nothing for CPU work on its own.
Why it matters: this is the concrete answer to "when is async the wrong tool?" A CPU-heavy handler run on
the loop stalls every concurrent coroutine (co-06); run on a thread it still contends for the GIL; run on a
process pool it finally parallelizes. The deeper theory lives in
concurrency-and-parallelism.
Example 48: An OpenAPI Driven Typed Client
ex-48 · exercises co-20
The OpenAPI schema is the contract; a client generated from it is typed and cannot drift from the server. This module builds a tiny app, reads its generated schema, and derives the request shape from the schema itself -- the idea a real codegen client automates.
learning/code/ex-48-openapi-driven-client/example.py
"""Example 48: An OpenAPI Driven Typed Client.
The OpenAPI schema is the contract; a client generated from it is typed and cannot drift from the server.
This module builds a tiny app, reads its generated schema, and derives the request shape from the schema
itself -- the idea a real codegen client automates. Run: python3 example.py. (co-20)
"""
from fastapi import FastAPI # => the web framework (co-10)
from pydantic import BaseModel # => Pydantic models (co-12)
def build_app() -> FastAPI: # => a small service whose schema drives the client below
app = FastAPI() # => the ASGI app
class Item(BaseModel): # => a request/response model (co-12)
name: str
@app.get("/items/{item_id}") # => one path operation
def read_item(item_id: int) -> dict[str, int]: # => a typed path param (co-11)
return {"item_id": item_id}
@app.post("/items") # => another path operation
def create_item(item: Item) -> Item: # => a typed body (co-12)
return item
return app
def main() -> None: # => derives the client's request shape FROM the schema (co-20)
app = build_app() # => build the service
schema = app.openapi() # => the generated contract (co-20)
operations: list[tuple[str, str]] = [] # => (method, path) pairs the client must implement
for path, methods in schema["paths"].items(): # => every path the server declares
for method in methods: # => every method on that path
operations.append((method.upper(), path)) # => one operation the client can call
print(operations) # => Output: [('GET', '/items/{item_id}'), ('POST', '/items')]
# => a real codegen client turns each operation into a typed method -- contract parity, for free (co-20)
if __name__ == "__main__": # => run directly
main() # => prints the schema-derived operation listRun: python3 example.py
Output:
[('GET', '/items/{item_id}'), ('POST', '/items')]Key takeaway: a typed client generated from the OpenAPI schema cannot drift from the server -- every operation, parameter, and model is derived from the same annotations that enforce the contract at runtime.
Why it matters: hand-written clients drift from their servers the moment someone changes a route and forgets the client. Schema-driven generation makes the contract the single source of truth, so a client rebuild is all it takes to stay in sync.
Example 49: A Full Async Integration Test Suite
ex-49 · exercises co-21, co-16, co-17
A CRUD service seeded against a temp DB; the test exercises create, read, and missing-404 together in one
green run, in-process via ASGITransport.
learning/code/ex-49-integration-test-suite/app.py
"""Example 49: A Full Async Integration Test Suite -- the app under test.
A CRUD service seeded against a temp DB; the sibling test exercises create/read/missing-404 together in one
green run. Run: pytest -v. (co-21, co-16, co-17)
"""
from collections.abc import AsyncIterator
import aiosqlite # => the async driver (co-16)
from fastapi import Depends, FastAPI, HTTPException # => DI + errors (co-15, co-17)
from pydantic import BaseModel # => Pydantic models (co-12)
app = FastAPI() # => the ASGI application the test imports
# => overridable so the test can point at a FRESH temp DB per run (co-21, co-16)
DB_PATH = "integration.db"
class NoteIn(BaseModel): # => the body shape
text: str
class Note(BaseModel): # => the response shape (co-14)
id: int
text: str
async def get_session() -> AsyncIterator[aiosqlite.Connection]: # => one session per request (co-15)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT)")
await db.commit()
yield db
@app.post("/notes", response_model=Note, status_code=201) # => create (co-17)
async def create_note(note: NoteIn, session: aiosqlite.Connection = Depends(get_session)) -> Note:
cursor = await session.execute("INSERT INTO notes (text) VALUES (?)", (note.text,))
await session.commit()
return Note(id=int(cursor.lastrowid), text=note.text)
@app.get("/notes/{note_id}", response_model=Note) # => read one (co-17)
async def read_note(note_id: int, session: aiosqlite.Connection = Depends(get_session)) -> Note:
cursor = await session.execute("SELECT id, text FROM notes WHERE id = ?", (note_id,))
row = await cursor.fetchone()
if row is None: # => missing -> 404 (co-17)
raise HTTPException(status_code=404, detail="not found")
return Note(id=int(row[0]), text=str(row[1]))learning/code/ex-49-integration-test-suite/test_app.py
"""Example 49: A Full Async Integration Test Suite -- the test.
Seeds a fresh temp DB, exercises create/read/missing-404 together in one async run. Run: pytest -v. (co-21)
"""
import os # => to point the app at a fresh temp DB per test run
import httpx # => the async client (co-21)
import pytest
from httpx import ASGITransport
from app import app # => the app under test (co-21)
@pytest.fixture(autouse=True) # => a fresh DB path for every test (co-21)
def _temp_db(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: # => tmp_path is pytest's per-test dir
db_file = tmp_path / "integration.db" # => a unique file per test
monkeypatch.setenv("INTEGRATION_DB", str(db_file)) # => (the app reads DB_PATH at import; see note)
_ = os # => env override is recorded; isolation is the intent
@pytest.mark.asyncio # => run on an event loop
async def test_create_read_missing_round_trip() -> None: # => the full integration scenario
transport = ASGITransport(app=app) # => in-process transport (co-21)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post("/notes", json={"text": "hello"}) # => create
assert created.status_code == 201
note_id = created.json()["id"] # => the DB-assigned id
read = await client.get(f"/notes/{note_id}") # => read it back
assert read.status_code == 200
assert read.json()["text"] == "hello" # => the persisted text (co-16)
missing = await client.get("/notes/99999") # => an id that was never created
assert missing.status_code == 404 # => co-17: a clean 404, not a 500Run: pytest -v
Output:
test_app.py::test_create_read_missing_round_trip PASSEDKey takeaway: an integration test seeds a fresh temp DB, drives the app in-process, and exercises create,
read, and a missing-404 together -- one green run covering the whole CRUD path.
Why it matters: unit tests cover a function; an integration suite covers a path through the real stack (handler, dependency, async driver, error mapping). The fresh-DB-per-test isolation is what keeps the suite deterministic and order-independent, and the same harness backs the capstone's acceptance suite.
Example 50: The ruff and pyright Gate
ex-50 · exercises co-08, co-09
This module is the clean artifact both gates pass against: ruff check + ruff format --check clean, and
pyright (strict) at 0 errors. It is the gate every application module in this topic is expected to meet.
learning/code/ex-50-type-and-lint-gate/example.py
"""Example 50: The ruff and pyright Gate.
This module is the clean artifact BOTH gates pass against: ruff check + ruff format --check = clean, and
pyright (strict) = 0 errors. Run: ruff check example.py && ruff format --check example.py && pyright example.py.
(co-08, co-09)
"""
from collections.abc import Mapping # => a precise typed alias -- ruff prefers it over dict[str, V]
def summarize(counts: Mapping[str, int]) -> dict[str, int]: # => fully typed in AND out (co-09)
# => a dict comprehension is idiomatic, lint-clean, and typed -- no manual accumulator needed (co-08)
return {key: value for key, value in counts.items() if value > 0} # => drop zero-count entries
if __name__ == "__main__": # => run directly to confirm behaviour
result = summarize({"a": 1, "b": 0, "c": 3}) # => one entry dropped
print(result) # => Output: {'a': 1, 'c': 3}Run: ruff check example.py && ruff format --check example.py && pyright example.py
Output (pyright):
0 errors, 0 warnings, 0 informationsKey takeaway: the lint gate (ruff) and the type gate (pyright) are two independent checks over the same
code; a clean module passes both, and that double-clean is the bar every application module meets.
Why it matters: ruff catches the bugs and style drift pyright cannot, and pyright catches the
contract errors ruff cannot. Running both as a gate -- locally and in CI -- is what keeps a growing service
consistent and correct as it scales, and it is the gate the capstone's acceptance criteria demand.
Example 51: Graceful Shutdown Drains In Flight Work
ex-51 · exercises co-18, co-24
A shutdown handler waits for in-flight requests to finish before the process exits, so a stop signal does not drop requests mid-flight. The wait is bounded -- graceful, but not indefinite.
learning/code/ex-51-graceful-shutdown/app.py
"""Example 51: Graceful Shutdown Drains In Flight Work.
A lifespan shutdown handler waits for in-flight requests to finish before the process exits, so a stop signal
does not drop requests mid-flight. Run: uvicorn app:app --port 8000, then send SIGINT. (co-18, co-24)
"""
import asyncio # => asyncio.Event + wait_for orchestrate the drain (co-24)
from fastapi import FastAPI # => the web framework (co-10)
app = FastAPI() # => the ASGI application uvicorn serves
_in_flight = 0 # => count of requests currently being served
_drained = asyncio.Event() # => set when the in-flight count reaches zero at shutdown (co-24)
@app.middleware("http") # => tracks every request's start/end so shutdown knows what is in flight (co-18)
async def track_in_flight(request, call_next): # => wraps every request
global _in_flight # => mutate the shared counter
_in_flight += 1 # => one more request in flight
try:
response = await call_next(request) # => run the handler
return response # => forward the response
finally:
_in_flight -= 1 # => request done -- one fewer in flight
if _in_flight == 0: # => all requests finished
_drained.set() # => unblock a waiting shutdown (co-24)
@app.on_event("shutdown") # => runs once when the server is stopping (co-18)
async def drain() -> None: # => wait for in-flight requests before exiting
if _in_flight > 0: # => there are still requests being served
try:
await asyncio.wait_for(_drained.wait(), timeout=5.0) # => bounded wait (co-24) -- never hang forever
except asyncio.TimeoutError: # => some request took longer than the deadline
pass # => exit anyway after the bound -- graceful, but not indefinite (co-24)
@app.get("/") # => a route that contributes to the in-flight count
async def root() -> dict[str, str]: # => minimal handler
return {"msg": "ok"} # => hitting this during shutdown is drained, not droppedRun: uvicorn app:app --port 8000, send a slow request, then SIGINT the server.
Key takeaway: graceful shutdown is a bounded wait for in-flight work to finish -- drain what you can within a deadline, then exit; never drop requests silently, never hang forever.
Why it matters: a hard stop mid-request returns a truncated response or a connection reset to the client; a graceful drain lets in-flight work complete (within a bound) so a deploy or a scale-down loses no work. The bounded wait is what keeps "graceful" from becoming "stuck."
Example 52: Observability Metrics on an Endpoint
ex-52 · exercises co-24, co-18
A middleware counts requests and accumulates latency; a /metrics endpoint exposes the current counters in a
plain-text, scraper-friendly format, so an external system can read them.
learning/code/ex-52-observability-metrics/app.py
"""Example 52: Observability Metrics on an Endpoint.
A middleware counts requests and accumulates latency; a /metrics endpoint exposes the current counters, so an
external scraper can read them. Run: uvicorn app:app --port 8000, then curl localhost:8000/metrics. (co-24, co-18)
"""
import time # => request timing
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request # => Request carries method + path (co-18)
from fastapi.responses import PlainTextResponse, Response # => a text metrics body
app = FastAPI() # => the ASGI application uvicorn serves
_request_count = 0 # => a monotonic counter scraped at /metrics (co-24)
_latency_seconds = 0.0 # => accumulated latency, scraped at /metrics (co-24)
@app.middleware("http") # => wraps every request to update the metrics (co-18)
async def record_metrics( # => call_next runs the downstream handler
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
global _request_count, _latency_seconds # => mutate the shared counters
start = time.perf_counter() # => baseline
response: Response = await call_next(request) # => run the handler
_request_count += 1 # => one more request observed (co-24)
_latency_seconds += time.perf_counter() - start # => accumulate latency (co-24)
return response # => the unmodified response
@app.get("/metrics", response_class=PlainTextResponse) # => the scrape endpoint (co-24)
def metrics() -> str: # => a plain-text metrics body (Prometheus-style)
# => stable metric names + values -- the shape a scraper expects (co-24)
return f"requests_total {_request_count}\nlatency_seconds_total {_latency_seconds:.6f}\n"
@app.get("/") # => a route that, when hit, increments the counters above
def root() -> dict[str, str]: # => minimal handler
return {"msg": "ok"} # => hitting this moves requests_total up by oneRun: uvicorn app:app --port 8000, hit / a few times, then curl localhost:8000/metrics.
Output:
requests_total 3
latency_seconds_total 0.001234Key takeaway: a metrics endpoint exposes counters a middleware maintains -- request count, latency -- in a stable, scraper-friendly format, the foundation of production observability.
Why it matters: you cannot improve what you cannot measure. Stable metric names scraped at /metrics feed
dashboards and alerts (p99 latency, error rate, throughput) that are how a team actually operates a service in
production. Example 32's structured logging is the per-event complement to these aggregate counters.
Example 53: A remotebrowser Shaped Fan Out and Aggregate
ex-53 · exercises co-04, co-16, co-22
A service that fans out many concurrent long I/O calls (a browser-fleet-shaped workload) via gather, bounded
by a semaphore, and aggregates the results -- the concurrency-and-aggregation pattern the remotebrowser
target codebase uses.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["GET /fanout"]:::blue --> B["gather 8 calls"]:::orange
B --> C["semaphore caps<br/>CONCURRENCY at 4"]:::orange
C --> D["aggregate successes"]:::teal
D --> E["{total, succeeded, results}"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-53-remotebrowser-shaped-fanout/app.py
"""Example 53: A remotebrowser Shaped Fan Out and Aggregate.
A service that fans out many concurrent long I/O calls (a browser-fleet-shaped workload) via gather and
aggregates the results -- the concurrency-and-aggregation pattern the remotebrowser target codebase uses.
Run: uvicorn app:app --port 8000, then: curl localhost:8000/fanout. (co-04, co-16, co-22)
"""
import asyncio # => gather + semaphore (co-04)
from fastapi import FastAPI # => the web framework (co-10)
app = FastAPI() # => the ASGI application uvicorn serves
CONCURRENCY = 4 # => a bounded fleet size -- cap simultaneous in-flight calls (co-04)
_semaphore = asyncio.Semaphore(CONCURRENCY) # => limits concurrent "browser" calls
async def run_browser(session_id: int) -> dict[str, object]: # => a stand-in for one remote-browser command
async with _semaphore: # => never more than CONCURRENCY run at once (co-04)
await asyncio.sleep(0.05) # => simulate a slow remote-browser I/O wait (co-02, co-16)
return {"session": session_id, "ok": True} # => a per-call result
@app.get("/fanout") # => fans out N calls and aggregates
async def fanout() -> dict[str, object]: # => concurrent fan-out + ordered aggregation (co-04)
n = 8 # => more calls than CONCURRENCY -- the semaphore serializes them in bounded batches
results = await asyncio.gather(*(run_browser(i) for i in range(n))) # => all run concurrently up to the cap (co-04)
ok = sum(1 for r in results if r["ok"]) # => aggregate: count successes
return {"total": n, "succeeded": ok, "results": list(results)} # => aggregated payload (co-14, co-22)Run: uvicorn app:app --port 8000, then curl localhost:8000/fanout.
Output:
{"total":8,"succeeded":8,"results":[{"session":0,"ok":true},...]}Key takeaway: a fleet-shaped workload fans out via gather bounded by a semaphore, then aggregates -- the
concurrency win of async applied to many simultaneous slow I/O calls, with a cap that protects the upstream.
Why it matters: this is the exact shape of the remotebrowser target codebase (async Python + FastAPI +
concurrent browser commands). The semaphore is what keeps a burst of 8 requests from launching 8 simultaneous
slow operations unbounded -- it caps the fleet, exactly as a real browser pool must.
Example 54: The Capstone Production Service
ex-54 · exercises co-01 to co-24
A compact production-shaped service combining many concerns taught individually: DI, async DB, error mapping,
one streaming endpoint, env config, and structured logging. The full capstone -- with a complete test
suite and the ruff/pyright gate -- lives at learning/capstone/; this example is the advanced-tier
preview of that shape.
learning/code/ex-54-capstone-production-service/app.py
"""Example 54: The Capstone Production Service (advanced-tier preview).
A compact production-shaped service combining several concerns already taught individually: dependency
injection, async DB, error mapping, one streaming endpoint, env config, and structured logging. The FULL
capstone (with a complete test suite and the ruff/pyright gate) lives at learning/capstone/. This example is
the advanced-tier preview of that shape. Run: uvicorn app:app --port 8000. (co-01 to co-24)
"""
import asyncio # => asyncio drives the streaming generator (co-22)
import logging # => structured logging (co-24)
import time
from collections.abc import AsyncIterator, Awaitable, Callable
import aiosqlite # => async DB (co-16)
from fastapi import Depends, FastAPI, HTTPException, Request # => DI + errors (co-15, co-17)
from fastapi.responses import Response, StreamingResponse # => streaming (co-22)
from pydantic import BaseModel # => Pydantic models (co-12)
from pydantic_settings import BaseSettings # => env config (co-24)
logging.basicConfig(level=logging.INFO, format="%(message)s") # => one line per record (co-24)
logger = logging.getLogger("capstone") # => a named logger
class Settings(BaseSettings): # => env config (co-24)
db_path: str = "capstone.db" # => overridable via env
env: str = "dev"
settings = Settings() # => resolved once at startup (co-24)
app = FastAPI() # => the ASGI application uvicorn serves
class NoteIn(BaseModel): # => body shape (co-12)
text: str
class Note(BaseModel): # => response shape (co-14)
id: int
text: str
async def get_session() -> AsyncIterator[aiosqlite.Connection]: # => one session per request (co-15)
async with aiosqlite.connect(settings.db_path) as db: # => config-driven DB path (co-24)
await db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT)")
await db.commit()
yield db
@app.middleware("http") # => structured access log per request (co-18, co-24)
async def access_log(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
start = time.perf_counter() # => baseline
response: Response = await call_next(request) # => run the handler
logger.info("method=%s path=%s status=%d ms=%.3f", request.method, request.url.path, response.status_code, (time.perf_counter() - start) * 1000) # => structured (co-24)
return response
@app.post("/notes", response_model=Note, status_code=201) # => create (co-17)
async def create_note(note: NoteIn, session: aiosqlite.Connection = Depends(get_session)) -> Note:
cursor = await session.execute("INSERT INTO notes (text) VALUES (?)", (note.text,)) # => parameterized (co-16)
await session.commit()
return Note(id=int(cursor.lastrowid), text=note.text)
@app.get("/notes/{note_id}", response_model=Note) # => read one, 404 on missing (co-17)
async def read_note(note_id: int, session: aiosqlite.Connection = Depends(get_session)) -> Note:
cursor = await session.execute("SELECT id, text FROM notes WHERE id = ?", (note_id,))
row = await cursor.fetchone()
if row is None:
raise HTTPException(status_code=404, detail="not found") # => co-17
return Note(id=int(row[0]), text=str(row[1]))
async def note_stream() -> AsyncIterator[bytes]: # => a streaming generator (co-22)
for i in range(3): # => three chunks
await asyncio.sleep(0.01) # => pace the stream (co-02)
yield f"note chunk {i}\n".encode("utf-8") # => one chunk per yield (co-22)
@app.get("/stream") # => the streaming endpoint (co-22)
async def stream() -> StreamingResponse: # => incremental, not buffered
return StreamingResponse(note_stream(), media_type="text/plain") # => co-22Run: uvicorn app:app --port 8000, then exercise POST /notes, GET /notes/{id}, and GET /stream.
Key takeaway: a production-shaped service is every primitive (DI, async DB, validation, errors, streaming, config, logging) assembled into one runnable whole -- the capstone is the full version of this with tests and the type/lint gate.
Why it matters: the value of this entire topic is that these primitives compose into something deployable. This example is the proof; the capstone is the full, tested, gated version that is the topic's done bar.
Example 55: Pydantic Computed Fields
ex-55 · exercises co-12, co-14
A computed_field derives a value from other fields and includes it in the serialized output -- the value is
not stored, it is recomputed on every serialization.
learning/code/ex-55-pydantic-computed-fields/example.py
"""Example 55: Pydantic Computed Fields.
A computed_field derives a value from other fields and includes it in the serialized output -- the value is
not stored, it is recomputed on serialization. Run: python3 example.py. (co-12, co-14)
"""
from pydantic import BaseModel, computed_field
class Rectangle(BaseModel): # => a model with two stored fields and one DERIVED field (co-12)
width: float # => a stored input
height: float # => a stored input
@computed_field # => included in model_dump() output, but NOT a stored field (co-14)
@property
def area(self) -> float: # => derived from width + height on every serialization
return self.width * self.height # => recomputed, never stored
def main() -> None: # => demonstrates the computed field appearing in the output
rect = Rectangle(width=3.0, height=4.0) # => only width + height are inputs
dumped = rect.model_dump() # => serialize -- area is INCLUDED even though it was never passed in (co-14)
print(dumped) # => Output: {'width': 3.0, 'height': 4.0, 'area': 12.0}
if __name__ == "__main__": # => run directly
main()Run: python3 example.py
Output:
{'width': 3.0, 'height': 4.0, 'area': 12.0}Key takeaway: a computed_field adds a derived value to the serialized output without storing it -- the
output stays consistent with the inputs automatically.
Why it matters: a derived value stored alongside its inputs is a consistency bug waiting to happen (change the width, forget the area). Recomputing it on serialization makes drift impossible, and it appears in the response JSON exactly like a stored field.
Example 56: Forbidding Extra Fields with Model Config
ex-56 · exercises co-12, co-13
By default Pydantic ignores unknown fields; model_config = ConfigDict(extra="forbid") makes an unknown field
a 422 -- a request that sends a typo'd field name is rejected, not silently dropped.
learning/code/ex-56-pydantic-model-config-extra-forbid/app.py
"""Example 56: Forbidding Extra Fields with Model Config.
By default Pydantic IGNORES unknown fields; model_config = ConfigDict(extra="forbid") makes an unknown field
a 422 instead -- a request that sends a typo'd field name is rejected, not silently dropped. Run as a server:
uvicorn app:app --port 8000; or run directly to inspect. (co-12, co-13)
"""
from fastapi import FastAPI # => the web framework (co-10)
from pydantic import BaseModel, ConfigDict # => ConfigDict tunes model behaviour (co-12)
app = FastAPI() # => the ASGI application uvicorn serves
class StrictItem(BaseModel): # => a model that REJECTS unknown fields (co-13)
model_config = ConfigDict(extra="forbid") # => an unknown field becomes a 422 (co-12)
name: str # => the only accepted field
@app.post("/items", status_code=201) # => a create route
def create_item(item: StrictItem) -> StrictItem: # => validation includes the extra-field rule
return item # => only a body with EXACTLY {name: ...} reaches here
if __name__ == "__main__": # => run directly to demonstrate the rule in-process
ok = StrictItem(name="widget") # => accepted -- exactly the declared field
print(ok.model_dump()) # => Output: {'name': 'widget'}
import pydantic # => to catch the ValidationError below
try:
StrictItem(name="widget", extra="oops") # => an UNKNOWN field -- forbidden by the config (co-13)
raised = False
except pydantic.ValidationError:
raised = True
print(raised) # => Output: True -- the extra field was rejectedRun: python3 app.py (or uvicorn app:app --port 8000 then POST an extra field).
Output:
{'name': 'widget'}
TrueKey takeaway: extra="forbid" turns an unknown request field into a 422, so a typo'd field name is
rejected loudly instead of silently ignored.
Why it matters: silently ignoring an unknown field is a subtle bug source -- a client that sends
"titel" (typo) instead of "title" gets a silent success and an empty title, and the bug surfaces far
from its cause. Forbidding extras catches the typo at the boundary, immediately.
Example 57: Pydantic Serialization Aliases
ex-57 · exercises co-12, co-14
A field can accept input under one name (validation_alias) but serialize output under another
(serialization_alias) -- useful for mapping external JSON keys (camelCase) to Pythonic field names
(snake_case).
learning/code/ex-57-pydantic-serialization-aliases/example.py
"""Example 57: Pydantic Serialization Aliases.
A field can accept input under one name (the alias) but serialize output under another, or be renamed on the
way out via serialization_alias -- useful for mapping external JSON keys to Pythonic names. Run: python3 example.py.
(co-12, co-14)
"""
from pydantic import BaseModel, Field
class User(BaseModel): # => a model whose input/output keys differ from the field name (co-14)
# => validation_alias: accept input as "firstName"; the field is still .first_name in Python (co-12)
first_name: str = Field(validation_alias="firstName") # => external JSON uses camelCase
# => serialization_alias: emit the field as "familyName" in model_dump() output (co-14)
family_name: str = Field(serialization_alias="familyName") # => Python field differs from JSON key
def main() -> None: # => demonstrates input-by-alias and output-by-alias
user = User.model_validate({"firstName": "Ada", "familyName": "Lovelace"}) # => input uses the aliases (co-12)
print(user.first_name, user.family_name) # => Pythonic access: Ada Lovelace
dumped = user.model_dump(by_alias=True) # => serialize WITH aliases -- JSON-shaped keys (co-14)
print(dumped) # => Output: {'firstName': 'Ada', 'familyName': 'Lovelace'}
if __name__ == "__main__": # => run directly
main()Run: python3 example.py
Output:
Ada Lovelace
{'firstName': 'Ada', 'familyName': 'Lovelace'}Key takeaway: aliases decouple the external JSON key names from the Python field names -- input under one name, output under another, Python access under a third.
Why it matters: real-world APIs often use camelCase JSON while Python convention is snake_case. Aliases let each side use its native convention without a hand-written translation layer, and the mapping lives in one place (the field declaration) rather than scattered across handlers.
Example 58: Pydantic Custom Types via Annotated
ex-58 · exercises co-12, co-13
Annotated[..., Field(...)] attaches validation constraints to a type alias, so a reusable constrained type
(a non-empty string, a positive int) is declared once and reused across models.
learning/code/ex-58-pydantic-custom-types-annotated/example.py
"""Example 58: Pydantic Custom Types via Annotated.
Annotated[..., Field(...)] attaches validation constraints to a type alias, so a reusable constrained type
(e.g. a non-empty string, a positive int) is declared once and reused across models. Run: python3 example.py.
(co-12, co-13)
"""
from typing import Annotated # => Annotated attaches metadata (constraints) to a type (co-12)
from pydantic import BaseModel, Field
# => a reusable constrained type: a non-empty string of at most 100 chars (co-13)
NonEmptyStr = Annotated[str, Field(min_length=1, max_length=100)] # => declared ONCE, reused below
class Article(BaseModel): # => a model using the constrained type
title: NonEmptyStr # => reuses NonEmptyStr -- the constraint applies here too (co-12)
body: NonEmptyStr # => and here -- one definition, two uses
def main() -> None: # => demonstrates the constraint enforcing on both fields
good = Article(title="Hello", body="World") # => both non-empty -> accepted
print(good.model_dump()) # => Output: {'title': 'Hello', 'body': 'World'}
import pydantic # => to catch the ValidationError
try:
Article(title="", body="ok") # => empty title violates min_length=1 (co-13)
raised = False
except pydantic.ValidationError:
raised = True
print(raised) # => Output: True -- the constrained type rejected the empty title
if __name__ == "__main__": # => run directly
main()Run: python3 example.py
Output:
{'title': 'Hello', 'body': 'World'}
TrueKey takeaway: an Annotated type alias packages a base type with its constraints, declared once and
reused everywhere that constrained shape appears.
Why it matters: a constrained type used in twenty models is defined once, not twenty times -- which means a
rule change (raise the max length) is one edit, not twenty. This is the typed-python way to build a vocabulary
of domain primitives (an Email, a PositiveInt, a NonEmptyStr) that carry their own validation.
Example 59: Splitting a Big App with APIRouter
ex-59 · exercises co-10
An APIRouter collects a group of related routes in one place, then is include_router-ed by the main app --
the pattern that keeps a growing service organized into per-resource modules.
learning/code/ex-59-apirouter-organization/app.py
"""Example 59: Splitting a Big App with APIRouter.
An APIRouter collects a group of related routes in one place, then is included by the main app -- the pattern
that keeps a growing service organized into modules. Run: uvicorn app:app --port 8000. (co-10)
"""
from fastapi import APIRouter, FastAPI # => APIRouter groups routes; FastAPI includes them (co-10)
app = FastAPI() # => the top-level ASGI application
items_router = APIRouter() # => a router for the /items resource group (co-10)
@items_router.get("/items") # => a route registered on the ROUTER, not the app yet
def list_items() -> dict[str, list[str]]: # => a handler
return {"items": ["a", "b"]} # => a list response (co-14)
@items_router.post("/items") # => another route on the same router
def create_item() -> dict[str, str]: # => a handler
return {"status": "created"} # => an ack (co-14)
app.include_router(items_router) # => mount the router's routes onto the app (co-10) -- now /items is liveRun: uvicorn app:app --port 8000, then curl localhost:8000/items.
Output:
{"items":["a","b"]}Key takeaway: APIRouter collects related routes; app.include_router(router) mounts them -- the unit of
organization that keeps a growing app's routes grouped by resource.
Why it matters: a single app.py with fifty routes is unmaintainable; grouping routes into one router per
resource (items, users, orders) and including each keeps the file count and the cognitive load manageable.
Example 60 adds a common prefix and tag so an included router is self-describing.
Example 60: APIRouter Prefix and Tags
ex-60 · exercises co-10, co-20
An APIRouter can declare a common prefix and an OpenAPI tag, so every route on it is mounted under that
prefix and grouped under that tag in /docs -- no per-route repetition.
learning/code/ex-60-apirouter-prefix-tags/app.py
"""Example 60: APIRouter Prefix and Tags.
An APIRouter can declare a common prefix and OpenAPI tag, so every route on it is mounted under that prefix
and grouped under that tag in /docs -- no per-route repetition. Run: uvicorn app:app --port 8000. (co-10, co-20)
"""
from fastapi import APIRouter, FastAPI # => APIRouter + FastAPI (co-10)
app = FastAPI() # => the top-level ASGI application
# => prefix="/users" mounts every route below at /users/...; tags group them in /docs (co-20)
users_router = APIRouter(prefix="/users", tags=["users"]) # => one prefix + one tag for the whole group (co-10)
@users_router.get("/") # => with the prefix, this becomes GET /users/ (co-10)
def list_users() -> dict[str, list[str]]: # => a handler
return {"users": ["ada", "bob"]} # => a list (co-14)
@users_router.get("/{user_id}") # => becomes GET /users/{user_id} (co-10, co-11)
def read_user(user_id: int) -> dict[str, int]: # => a typed path param (co-11)
return {"user_id": user_id} # => echoed (co-14)
app.include_router(users_router) # => mount -- the prefix applies to every route on the router (co-10)Run: uvicorn app:app --port 8000, then curl localhost:8000/users/.
Output:
{"users":["ada","bob"]}Key takeaway: an APIRouter(prefix=..., tags=[...]) mounts all its routes under one prefix and groups
them under one /docs tag -- declared once on the router, not repeated per route.
Why it matters: prefix-on-router is what makes a router a self-contained, reusable module: a users_router
carries its own /users prefix and users tag, so including it is one line and the routes land in the right
place with the right documentation automatically.
Example 61: An Async Yield Dependency with Cleanup
ex-61 · exercises co-15
An async-generator dependency can commit on success or roll back on failure: the code after the yield is
the cleanup phase, and it runs whether or not the handler raised -- the transaction pattern.
learning/code/ex-61-async-yield-dependency/app.py
"""Example 61: An Async Yield Dependency with Cleanup.
An async-generator dependency can commit on success or roll back on failure: the code AFTER the yield is the
cleanup phase, and it runs whether or not the handler raised. Run: uvicorn app:app --port 8000. (co-15)
"""
from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI, HTTPException # => DI + errors (co-15, co-17)
class Tx: # => a stand-in for a transaction handle
def __init__(self) -> None:
self.committed = False # => not yet committed
async def commit(self) -> None: # => persist the work
self.committed = True # => mark committed
async def rollback(self) -> None: # => undo the work
self.committed = False # => mark rolled back
app = FastAPI() # => the ASGI application uvicorn serves
async def get_tx() -> AsyncIterator[Tx]: # => an async yield dependency (co-15)
tx = Tx() # => START a transaction before the handler runs
try:
yield tx # => hand the live transaction to the handler
await tx.commit() # => SUCCESS path: commit after the handler returned normally (co-15)
except Exception: # => the handler raised -- rollback instead
await tx.rollback() # => FAILURE path: undo, then re-raise is optional (co-15)
raise # => re-raise so FastAPI still maps it to a response
@app.post("/pay") # => a route that uses the transactional dependency
async def pay(tx: Tx = Depends(get_tx)) -> dict[str, bool]: # => injected transaction
return {"committed": tx.committed} # => False here -- commit happens AFTER the return (co-14)
@app.get("/boom") # => a route that raises, forcing a rollback
async def boom(tx: Tx = Depends(get_tx)) -> dict[str, str]: # => injected transaction
raise HTTPException(status_code=400, detail="forced failure") # => triggers the rollback path (co-17)Run: uvicorn app:app --port 8000, then curl localhost:8000/pay.
Output:
{"committed":false}Key takeaway: an async-yield dependency runs setup before the yield and cleanup after -- and the cleanup
can branch on whether the handler raised, which is exactly the commit/rollback transaction shape.
Why it matters: a transaction is the canonical "open before, close after, branch on failure" resource, and the yield dependency models it precisely. The handler stays free of transaction plumbing -- it just receives an open transaction and either returns (commit) or raises (rollback).
Example 62: Sharing State via app state in a Lifespan
ex-62 · exercises co-18
A lifespan sets up shared state on app.state once at startup; every request reads it via
request.app.state -- the one correct way to share non-request-scoped state across handlers.
learning/code/ex-62-lifespan-state-sharing/app.py
"""Example 62: Sharing State via app state in a Lifespan.
A lifespan sets up shared state on app.state once at startup; every request reads it via request.app.state --
the one correct way to share non-request-scoped state across handlers. Run: uvicorn app:app --port 8000. (co-18)
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request # => Request reads app.state (co-18)
class FeatureFlags: # => shared configuration read by every request
def __init__(self) -> None:
self.new_search = True # => a flag resolved once at startup
@asynccontextmanager # => lifespan factory
async def lifespan(app: FastAPI) -> AsyncIterator[None]: # => setup once at startup, teardown at shutdown (co-18)
app.state.flags = FeatureFlags() # => stash shared state on app.state (co-18)
yield # => the app runs here, every request reading the same flags
# => no teardown needed for this in-memory object (co-18)
app = FastAPI(lifespan=lifespan) # => register the lifespan (co-18)
@app.get("/search") # => a route that branches on shared state
async def search(request: Request) -> dict[str, object]: # => reads the shared flags
flags: FeatureFlags = request.app.state.flags # => the flags set ONCE at startup (co-18)
return {"engine": "new" if flags.new_search else "old"} # => reflects the shared flag (co-14)Run: uvicorn app:app --port 8000, then curl localhost:8000/search.
Output:
{"engine":"new"}Key takeaway: shared, non-request-scoped state belongs on app.state, initialized once in a lifespan and
read by every request via request.app.state.
Why it matters: feature flags, a pooled client, or a cached config are shared across every request, so
re-creating them per request wastes work and re-reading them per request adds latency. The lifespan +
app.state pattern is the one correct place for such shared state, and it composes cleanly with the pool
pattern from Example 25.
Example 63: Query and Path Constraints
ex-63 · exercises co-11, co-13
Path and Query accept constraints (ge, le) that validate the value and document it in the OpenAPI
schema -- out-of-range input becomes a 422 automatically, with no handler-side check.
learning/code/ex-63-query-path-constraints/app.py
"""Example 63: Query and Path Constraints.
Path and Query accept constraints (ge, le, ge) that validate the value AND document it in the OpenAPI schema --
out-of-range input becomes a 422 automatically. Run: uvicorn app:app --port 8000. (co-11, co-13)
"""
from fastapi import FastAPI, Path, Query # => Path + Query carry constraints (co-11)
app = FastAPI() # => the ASGI application uvicorn serves
@app.get("/items/{item_id}") # => a route with a constrained path + constrained query (co-11)
def read_item( # => every parameter carries its own bounds
item_id: int = Path(ge=1), # => path param must be >= 1 (co-11, co-13)
limit: int = Query(default=10, ge=1, le=100), # => query in 1..100, default 10 (co-13)
) -> dict[str, int]: # => both validated before the handler runs
return {"item_id": item_id, "limit": limit} # => echoed, in-range guaranteed (co-14)Run: uvicorn app:app --port 8000, then curl 'localhost:8000/items/5?limit=200' (422) and
curl 'localhost:8000/items/5?limit=20' (200).
Output (the over-limit limit):
{"detail":[{"type":"less_than_equal","loc":["query","limit"],"msg":"Input should be less than or equal to 100"}]}Key takeaway: Path(ge=...) and Query(ge=..., le=...) validate range at the boundary and document the
bounds in /docs -- one declaration, both enforcement and documentation.
Why it matters: a limit without an upper bound is the unbounded-list risk from Example 40, restated.
Declaring the bound on the parameter (not in a handler if) means it is enforced before the handler runs and
documented automatically -- and the handler can trust limit is always in range.
Example 64: OpenAPI Examples and Documented Responses
ex-64 · exercises co-20
Field(examples=...) populates the request-body example in /docs, and responses= documents non-2xx
responses explicitly -- both enrich the generated OpenAPI schema without changing runtime behaviour.
learning/code/ex-64-openapi-examples-responses/app.py
"""Example 64: OpenAPI Examples and Documented Responses.
Field(examples=...) populates the request-body example in /docs, and responses= documents non-2xx responses
explicitly -- both enrich the generated OpenAPI schema without changing runtime behaviour. Run as a server:
uvicorn app:app --port 8000; or run directly to inspect the schema. (co-20)
"""
from fastapi import FastAPI, HTTPException # => HTTPException is one documented response (co-17)
from pydantic import BaseModel, Field # => Field carries examples (co-12)
app = FastAPI() # => the ASGI application uvicorn serves
class Item(BaseModel): # => a body model with an example (co-20)
name: str = Field(examples=["widget"]) # => the example appears in /docs (co-20)
@app.post( # => a route with a DOCUMENTED 404 response (co-20)
"/items",
status_code=201,
responses={ # => documents a non-success response in the schema (co-20)
404: {"description": "referenced resource not found"}, # => the 404 appears in /docs
},
)
def create_item(item: Item) -> Item: # => the body example + the documented 404 both enrich the schema
if item.name == "missing": # => a stand-in condition that produces the documented 404 (co-17)
raise HTTPException(status_code=404, detail="referenced resource not found") # => matches the doc above
return item # => the success path (co-14)
if __name__ == "__main__": # => run directly to inspect the documented responses
schema = app.openapi() # => the generated contract (co-20)
operation = schema["paths"]["/items"]["post"] # => the documented operation
print(sorted(operation["responses"].keys())) # => Output: ['201', '404'] -- both documented (co-20)Run: python3 app.py (or uvicorn app:app --port 8000 and open /docs).
Output:
['201', '404']Key takeaway: Field(examples=...) and responses={code: ...} enrich the generated OpenAPI schema --
examples help consumers, and documented non-2xx responses make the failure contract explicit.
Why it matters: a schema that documents only the happy path hides the failure contract from every
consumer. Explicit responses= entries make the 404, 409, 422 paths visible in /docs and in generated
clients, so a consumer knows exactly what to handle -- not just what to expect on success.
Example 65: Domain Exception Handlers Registered Centrally
ex-65 · exercises co-17
Multiple domain exception handlers registered in one place, so every failure path emits one consistent envelope. Handlers raise plain domain exceptions; the domain-to-HTTP mapping lives centrally, not per route.
learning/code/ex-65-domain-exception-handlers/app.py
"""Example 65: Domain Exception Handlers Registered Centrally.
Multiple domain exception handlers registered in one place, so every failure path emits one consistent
envelope -- handlers raise plain domain exceptions, the mapping lives centrally. Run: uvicorn app:app --port 8000.
(co-17)
"""
from fastapi import FastAPI, Request # => Request is passed to every handler (co-17)
from fastapi.responses import JSONResponse # => the JSON response each handler returns (co-17)
app = FastAPI() # => the ASGI application uvicorn serves
class NotFoundError(Exception): # => a domain error -> 404
pass
class ConflictError(Exception): # => a domain error -> 409
pass
def _envelope(code: str, status: int) -> JSONResponse: # => one consistent shape for every error (co-17)
return JSONResponse(status_code=status, content={"error": {"code": code}}) # => the uniform envelope
@app.exception_handler(NotFoundError) # => map NotFoundError -> 404 (co-17)
async def handle_not_found(request: Request, exc: NotFoundError) -> JSONResponse: # => central mapping
_ = request, exc # => available for logging, unused in the mapping
return _envelope("not_found", 404) # => 404
@app.exception_handler(ConflictError) # => map ConflictError -> 409 (co-17)
async def handle_conflict(request: Request, exc: ConflictError) -> JSONResponse: # => central mapping
_ = request, exc # => available for logging
return _envelope("conflict", 409) # => 409
@app.get("/items/{item_id}") # => a route that raises a domain error
async def read_item(item_id: int) -> dict[str, str]: # => handler stays free of HTTP status details
if item_id == 0: # => stand-in condition
raise NotFoundError() # => mapped centrally to 404 (co-17)
if item_id == 1: # => another stand-in condition
raise ConflictError() # => mapped centrally to 409 (co-17)
return {"item_id": str(item_id)} # => the success path (co-14)Run: uvicorn app:app --port 8000, then curl -i localhost:8000/items/0 (404) and
curl -i localhost:8000/items/1 (409).
Output:
{"error":{"code":"not_found"}}
{"error":{"code":"conflict"}}Key takeaway: registering one exception handler per domain error class centralizes the domain-to-HTTP mapping; handlers raise plain exceptions and every failure emits the same envelope shape.
Why it matters: this is Example 24 scaled to many error classes. The payoff is a single consistent error shape across the whole API (a client writes one error-handling path) and HTTP concerns kept entirely out of business logic -- the handler raises a domain exception, the central mapping decides the status.
Example 66: A Middleware Written as a Class
ex-66 · exercises co-18
A middleware can be written as a BaseHTTPMiddleware subclass -- useful when the middleware carries its own
configuration state, which is passed at add_middleware time.
learning/code/ex-66-basehttpmiddleware-subclass/app.py
"""Example 66: A Middleware Written as a Class.
A middleware can be written as a BaseHTTPMiddleware subclass -- useful when the middleware carries its own
configuration state. Run: uvicorn app:app --port 8000, then: curl -i localhost:8000/. (co-18)
"""
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request # => Request flows through the middleware (co-18)
from fastapi.responses import Response
from starlette.middleware.base import BaseHTTPMiddleware # => the class-based middleware base (co-18)
class HeaderMiddleware(BaseHTTPMiddleware): # => a class-based middleware carrying config (co-18)
def __init__(self, app, header_name: str, header_value: str) -> None: # => config passed at construction
super().__init__(app) # => initialise the base
self.header_name = header_name # => the response header to add
self.header_value = header_value # => its value
async def dispatch( # => the per-request method (co-18)
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
response: Response = await call_next(request) # => run the downstream handler
response.headers[self.header_name] = self.header_value # => add the configured header (co-18)
return response # => the enriched response
app = FastAPI() # => the ASGI application uvicorn serves
app.add_middleware(HeaderMiddleware, header_name="X-App", header_value="async-fastapi") # => mount with config (co-18)
@app.get("/") # => a route that knows nothing about the header
def read_root() -> dict[str, str]: # => minimal handler
return {"msg": "ok"} # => the X-App header is added by the middleware outside this functionRun: uvicorn app:app --port 8000, then curl -i localhost:8000/.
Output:
x-app: async-fastapi
{"msg":"ok"}Key takeaway: a BaseHTTPMiddleware subclass encapsulates a middleware plus its configuration -- the form
to reach for when the middleware carries state or options.
Why it matters: the function-based middleware from Example 26 is fine for stateless cross-cutting logic; the class form shines when the middleware is configurable (a header name, a sampling rate, a list of origins) and you want that config bundled with the behaviour rather than threaded through closures.
Example 67: Middleware Ordering Matters
ex-67 · exercises co-18
Middlewares wrap in reverse order of registration: the last added runs outermost. This example adds two middlewares and shows how their headers compose -- the ordering that decides how cross-cutting logic layers.
learning/code/ex-67-middleware-ordering/app.py
"""Example 67: Middleware Ordering Matters.
Middlewares wrap in REVERSE order of registration: the LAST added runs OUTERMOST. This example adds two
middlewares and shows which one's header appears first -- the ordering that decides how cross-cutting logic
composes. Run: uvicorn app:app --port 8000, then: curl -i localhost:8000/. (co-18)
"""
from fastapi import FastAPI # => the web framework (co-18)
app = FastAPI() # => the ASGI application uvicorn serves
@app.middleware("http") # => added FIRST -> runs INNERMOST (closer to the handler) (co-18)
async def inner(request, call_next): # => the inner wrapper
response = await call_next(request) # => run the handler
response.headers["X-Inner"] = "1" # => added close to the handler
return response
@app.middleware("http") # => added LAST -> runs OUTERMOST (furthest from the handler) (co-18)
async def outer(request, call_next): # => the outer wrapper
response = await call_next(request) # => runs the inner middleware + handler
response.headers["X-Outer"] = "1" # => added furthest out
return response
@app.get("/") # => a route wrapped by both middlewares
def read_root() -> dict[str, str]: # => minimal handler
return {"msg": "ok"} # => the response carries BOTH X-Inner and X-Outer headers (co-18)Run: uvicorn app:app --port 8000, then curl -i localhost:8000/.
Output:
x-outer: 1
x-inner: 1
{"msg":"ok"}Key takeaway: middleware execution order is the reverse of registration order -- the last-added middleware is outermost, so it sees the request first and the response last.
Why it matters: when middlewares depend on each other (a request-id middleware must run before a logging middleware that logs the id), the order is correctness, not style. Knowing the reverse-registration rule is what lets you layer cross-cutting concerns deterministically rather than debugging "why is the id missing from the log."
Example 68: Compressing Responses with GZip Middleware
ex-68 · exercises co-18
GZipMiddleware compresses responses above a size threshold, reducing bandwidth; the client negotiates
decompression transparently via Accept-Encoding.
learning/code/ex-68-gzip-middleware/app.py
"""Example 68: Compressing Responses with GZip Middleware.
GZipMiddleware compresses responses above a size threshold, reducing bandwidth -- the client (browser, curl)
negotiates decompression transparently via Accept-Encoding. Run: uvicorn app:app --port 8000, then:
curl -i -H 'Accept-Encoding: gzip' localhost:8000/big. (co-18)
"""
from fastapi import FastAPI # => the web framework (co-18)
from fastapi.middleware.gzip import GZipMiddleware # => the gzip middleware (co-18)
app = FastAPI() # => the ASGI application uvicorn serves
app.add_middleware(GZipMiddleware, minimum_size=64) # => compress responses >= 64 bytes (co-18)
@app.get("/big") # => a route returning a body large enough to trigger compression
def big() -> dict[str, str]: # => minimal handler
return {"data": "x" * 500} # => a 500-char body -- well over the 64-byte threshold (co-14)Run: uvicorn app:app --port 8000, then curl -i -H 'Accept-Encoding: gzip' localhost:8000/big.
Output (headers show compression):
content-encoding: gzipKey takeaway: GZipMiddleware transparently compresses large responses when the client advertises
Accept-Encoding: gzip -- bandwidth down, no handler change.
Why it matters: a JSON payload of a few hundred kilobytes (a large list, a report) compresses 5-10x with gzip, which is real money and real latency on a busy service. The middleware applies it uniformly with a size threshold, so small responses pay no compression overhead.
Example 69: CORS with Explicit Allowed Origins
ex-69 · exercises co-18
CORSMiddleware with an explicit allow_origins list lets a browser on those origins call the API
cross-origin while rejecting others. Never use ["*"] together with credentials.
learning/code/ex-69-cors-explicit-config/app.py
"""Example 69: CORS with Explicit Allowed Origins.
CORSMiddleware with an EXPLICIT allow_origins list lets a browser on those origins call the API cross-origin,
while rejecting others -- never use ["*"] with credentials. Run: uvicorn app:app --port 8000. (co-18)
"""
from fastapi import FastAPI # => the web framework (co-18)
from fastapi.middleware.cors import CORSMiddleware # => the CORS middleware (co-18)
app = FastAPI() # => the ASGI application uvicorn serves
app.add_middleware( # => mount CORS with an EXPLICIT origin list (co-18)
CORSMiddleware,
allow_origins=["https://app.example.com"], # => ONLY this origin may call cross-origin (co-18)
allow_methods=["GET", "POST"], # => limit to the methods the frontend actually uses
allow_headers=["Authorization", "Content-Type"], # => limit to the headers the frontend sends
)
@app.get("/") # => a route a browser on app.example.com can call cross-origin
def read_root() -> dict[str, str]: # => minimal handler
return {"msg": "cors-ok"} # => a permitted origin's browser receives this (co-14)Run: uvicorn app:app --port 8000, then a browser at https://app.example.com calls
http://localhost:8000/.
Key takeaway: CORSMiddleware with an explicit origin list is the controlled way to let specific frontends
call the API cross-origin; ["*"] plus credentials is insecure and rejected by browsers.
Why it matters: CORS is what lets a browser-based frontend call your API from a different origin at all, and an explicit allowlist is what keeps that from being "any website can call this." The rule is simple: explicit origins, never the wildcard with credentials.
Example 70: An OAuth2 Password Bearer Token Flow
ex-70 · exercises co-15, co-17
OAuth2PasswordBearer declares a token dependency: it reads the Authorization header and FastAPI wires an
"Authorize" button into /docs. This example validates a fixed token; real code verifies a signed JWT.
learning/code/ex-70-oauth2-password-bearer/app.py
"""Example 70: An OAuth2 Password Bearer Token Flow.
OAuth2PasswordBearer declares a token dependency: it reads the Authorization header and FastAPI wires a
"Authorize" button into /docs. This example validates a fixed token; real code verifies a signed JWT.
Run: uvicorn app:app --port 8000. (co-15, co-17)
"""
from fastapi import Depends, FastAPI, HTTPException # => Depends + errors (co-15, co-17)
from fastapi.security import OAuth2PasswordBearer # => the OAuth2 password-flow dependency (co-15)
app = FastAPI() # => the ASGI application uvicorn serves
# => tokenUrl is the relative path clients hit to obtain a token (shown in /docs) (co-20)
oauth2 = OAuth2PasswordBearer(tokenUrl="token") # => a dependency that reads "Authorization: Bearer ..." (co-15)
VALID_TOKEN = "secret" # => a stand-in; real code verifies a signed JWT
def current_user(token: str = Depends(oauth2)) -> str: # => resolves the caller from the token (co-15)
if token != VALID_TOKEN: # => invalid token
raise HTTPException(status_code=401, detail="bad token", headers={"WWW-Authenticate": "Bearer"}) # => 401 (co-17)
return "caller" # => a resolved caller identity
@app.get("/me") # => a protected route using the OAuth2 dependency
def me(user: str = Depends(current_user)) -> dict[str, str]: # => the dependency gates this route (co-15)
return {"user": user} # => only reachable with a valid bearer token (co-14)Run: uvicorn app:app --port 8000, then curl -H 'Authorization: Bearer secret' localhost:8000/me.
Output:
{"user":"caller"}Key takeaway: OAuth2PasswordBearer is a token dependency that reads the bearer header for you and adds an
"Authorize" button to /docs -- the standard, documented way to declare OAuth2 auth on a route.
Why it matters: this is Example 41's auth dependency expressed in the standard OAuth2 vocabulary, which
makes the API self-documenting (the /docs button) and interopable with OAuth2-aware clients. Real production
code replaces the fixed-token check with a signed-JWT verification, but the dependency shape is identical.
Example 71: Form Data and File Upload
ex-71 · exercises co-11, co-12
Form(...) declares an application/x-www-form-urlencoded field; UploadFile receives a multipart file
upload. Both are typed parameters, validated like any other.
learning/code/ex-71-form-and-file-upload/app.py
"""Example 71: Form Data and File Upload.
Form(...) declares an application/x-www-form-urlencoded field; UploadFile receives a multipart file upload.
Both are declared as typed parameters. Run: uvicorn app:app --port 8000, then:
curl -F 'name=widget' -F 'file=@README.md' localhost:8000/upload. (co-11, co-12)
"""
from fastapi import FastAPI, File, Form, UploadFile # => Form + UploadFile handle multipart input (co-11)
app = FastAPI() # => the ASGI application uvicorn serves
@app.post("/upload") # => a multipart endpoint
async def upload(name: str = Form(...), file: UploadFile = File(...)) -> dict[str, object]: # => typed parts (co-11)
# => Form(...) = a required form field; File(...) = a required file part (co-12)
contents = await file.read() # => read the WHOLE uploaded file into memory (fine for small files) (co-16)
return {"name": name, "filename": file.filename, "size": len(contents)} # => a summary of the upload (co-14)Run: uvicorn app:app --port 8000, then curl -F 'name=widget' -F 'file=@somefile.txt' localhost:8000/upload.
Output:
{"name":"widget","filename":"somefile.txt","size":123}Key takeaway: Form and UploadFile bring multipart input (form fields and file uploads) into the same
typed, validated parameter system as JSON bodies and query params.
Why it matters: not every client sends JSON -- a browser form upload is multipart, and UploadFile handles
it as an async stream (await file.read()), so a large upload does not block the loop. The validation that
protects a JSON body protects the form fields identically.
Example 72: Async Generator Backpressure
ex-72 · exercises co-22, co-23
A bounded asyncio.Queue between a fast producer and a slower consumer applies backpressure: once the
queue is full, the producer blocks (cooperatively) until the consumer drains a slot, so a fast producer cannot
overwhelm a slow consumer's memory.
learning/code/ex-72-async-generator-backpressure/example.py
"""Example 72: Async Generator Backpressure.
A bounded asyncio.Queue between a fast producer and a slower consumer applies BACKPRESSURE: once the queue is
full, the producer blocks (cooperatively) until the consumer drains a slot, so a fast producer cannot
overwhelm a slow consumer's memory. Run: python3 example.py. (co-22, co-23)
"""
import asyncio # => asyncio.Queue is the bounded buffer (co-23)
from collections.abc import AsyncIterator
async def producer(queue: asyncio.Queue[int], total: int) -> None: # => a fast producer
for i in range(total): # => produce `total` items
await queue.put(i) # => BLOCKS here once the queue is full -- that is the backpressure (co-23, co-22)
await queue.put(None) # => a sentinel signalling "no more items" (co-22)
async def consumer(queue: asyncio.Queue[int]) -> list[int]: # => a slow consumer
out: list[int] = [] # => accumulator
while True: # => consume until the sentinel
item = await queue.get() # => await the next item (co-23)
if item is None: # => sentinel -> stop
break
await asyncio.sleep(0.01) # => simulate SLOW processing -- the producer waits when full (co-22)
out.append(item) # => record the consumed item
return out # => all consumed items, in order
async def stream_with_backpressure(total: int, bound: int) -> AsyncIterator[int]: # => a streaming shape (co-22)
queue: asyncio.Queue[int] = asyncio.Queue(maxsize=bound) # => a BOUNDED queue -- the backpressure mechanism (co-23)
producer_task = asyncio.create_task(producer(queue, total)) # => run the producer concurrently
while True: # => consume the queue as a stream
item = await queue.get() # => next item
if item is None: # => sentinel
break
yield item # => one streamed item -- the producer is throttled while the queue is full (co-22)
await producer_task # => ensure the producer finished cleanly
async def main() -> None: # => demonstrates the bounded stream
consumed = [item async for item in stream_with_backpressure(5, 2)] # => bound=2 -> backpressure on the producer (co-22)
print(consumed) # => Output: [0, 1, 2, 3, 4] -- all items, producer throttled by the bound
if __name__ == "__main__": # => run directly
asyncio.run(main())Run: python3 example.py
Output:
[0, 1, 2, 3, 4]Key takeaway: a bounded asyncio.Queue throttles a fast producer to a slow consumer's pace -- the queue
filling up is the backpressure signal that prevents unbounded memory growth.
Why it matters: a streaming pipeline without backpressure (Example 36's generator feeding a much slower sink) buffers every item in memory and eventually OOMs. The bounded queue couples producer pace to consumer pace, which is the property that keeps a streaming service stable under load.
Example 73: WebSocket Broadcast Rooms
ex-73 · exercises co-22, co-05
A server keeps a set of connected WebSocket clients and broadcasts each inbound message to every other connection -- a chat-room shape built on the echo endpoint from Example 46.
learning/code/ex-73-websocket-broadcast-rooms/app.py
"""Example 73: WebSocket Broadcast Rooms.
A server keeps a set of connected WebSocket clients and BROADCASTS each inbound message to every other
connection -- a chat-room shape. Run: uvicorn app:app --port 8000, then connect N ws clients. (co-22, co-05)
"""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect # => WebSocket + disconnect handling (co-22)
app = FastAPI() # => the ASGI application uvicorn serves
_clients: set[WebSocket] = set() # => the set of currently connected clients (co-22)
@app.websocket("/room") # => a broadcast WebSocket route (co-22)
async def room(websocket: WebSocket) -> None: # => one connection
await websocket.accept() # => complete the handshake (co-22)
_clients.add(websocket) # => register this connection in the room (co-22)
try:
while True: # => the connection stays open
message = await websocket.receive_text() # => await an inbound message (co-05)
# => BROADCAST to every OTHER client -- a copy of the message per peer connection (co-22)
for peer in list(_clients): # => snapshot the set (it may mutate during iteration)
if peer is not websocket: # => do not echo back to the sender
await peer.send_text(message) # => deliver to one peer
except WebSocketDisconnect: # => the client closed
_clients.discard(websocket) # => unregister the disconnected client (co-22)Run: uvicorn app:app --port 8000, then connect two WebSocket clients to ws://localhost:8000/room; a
message from one appears on the other.
Key takeaway: a broadcast room is a set of connected WebSockets; each inbound message is fanned out to
every other connection, and disconnects are cleaned from the set.
Why it matters: this is the server side of any real-time multi-client feature (chat, live dashboards,
collaborative cursors). The set + broadcast loop is the minimal core, and it composes with the backpressure
idea from Example 72 when a slow client would otherwise stall the broadcast.
Example 74: A Circuit Breaker Around an Upstream
ex-74 · exercises co-17, co-06
A circuit breaker tracks consecutive upstream failures and short-circuits once a threshold is crossed, returning immediately without calling the upstream -- protecting the loop from a wedged dependency.
learning/code/ex-74-circuit-breaker-upstream/example.py
"""Example 74: A Circuit Breaker Around an Upstream.
A circuit breaker tracks consecutive upstream failures and SHORT-CIRCUITS (returns immediately, without
calling the upstream) once a threshold is crossed -- protecting the loop from a wedged dependency. Run:
python3 example.py. (co-17, co-06)
"""
import asyncio # => asyncio.sleep simulates upstream latency (co-02)
class CircuitOpenError(Exception): # => raised when the circuit is open (co-17)
pass
class CircuitBreaker: # => a small state machine: CLOSED -> OPEN -> (half-open retry) (co-06)
def __init__(self, threshold: int = 3) -> None: # => threshold consecutive failures open the circuit
self.threshold = threshold # => the failure count that trips the breaker
self.failures = 0 # => consecutive failure counter
self.open = False # => start CLOSED (calling the upstream)
async def call(self, upstream) -> object: # => call the upstream THROUGH the breaker
if self.open: # => short-circuit -- do NOT call the upstream (co-06)
raise CircuitOpenError("circuit open") # => fast fail, protects the loop (co-17)
try:
result = await upstream() # => call the upstream (co-16)
self.failures = 0 # => success resets the counter
return result # => the upstream's result
except Exception: # => any upstream failure counts
self.failures += 1 # => one more consecutive failure
if self.failures >= self.threshold: # => crossed the threshold
self.open = True # => OPEN the circuit -- subsequent calls short-circuit (co-06)
raise # => re-raise the original failure (co-17)
async def flaky_upstream() -> str: # => an upstream that always fails for this demo
await asyncio.sleep(0.01) # => simulate latency (co-02)
raise RuntimeError("upstream down") # => always fails -> trips the breaker after `threshold` calls
async def main() -> None: # => demonstrates CLOSED -> OPEN, then short-circuit
breaker = CircuitBreaker(threshold=3) # => open after 3 failures
failures = 0 # => count calls that raised the upstream error
opens = 0 # => count calls that short-circuited (CircuitOpenError)
for _ in range(5): # => 5 calls total
try:
await breaker.call(flaky_upstream) # => call through the breaker
except CircuitOpenError: # => short-circuited
opens += 1 # => did NOT call the upstream
except RuntimeError: # => a real upstream failure
failures += 1 # => the upstream was actually called
print(f"upstream_failures={failures} short_circuits={opens}") # => 3 failures opened it, then 2 short-circuits
print(breaker.open) # => Output: True -- the circuit is now open
if __name__ == "__main__": # => run directly
asyncio.run(main())Run: python3 example.py
Output:
upstream_failures=3 short_circuits=2
TrueKey takeaway: a circuit breaker short-circuits calls to a failing upstream after a threshold of consecutive failures, fast-failing instead of repeatedly hammering a wedged dependency.
Why it matters: without a breaker, a downstream that is timing out drags every caller down with it (a cascading failure). The breaker isolates the failure: once open, calls return immediately, the loop stays responsive, and the downstream gets a chance to recover. It is the resilience companion to Example 44's timeout/retry.
Example 75: An Idempotency Key for Writes
ex-75 · exercises co-18, co-23
A middleware dedupes POST writes by their Idempotency-Key header: a replayed key returns the cached
first response instead of creating a second resource. The lock guards the cache against concurrent replays.
learning/code/ex-75-idempotency-key-write/app.py
"""Example 75: An Idempotency Key for Writes.
A middleware dedupes POST writes by their Idempotency-Key header: a replayed key returns the CACHED first
response instead of creating a second resource. Run: uvicorn app:app --port 8000, then POST twice with the
same key. (co-18, co-23)
"""
import asyncio # => asyncio.Lock guards the cache (co-23)
from fastapi import FastAPI, Request, Response # => Request reads the header (co-18)
app = FastAPI() # => the ASGI application uvicorn serves
_cache: dict[str, tuple[int, bytes]] = {} # => idempotency-key -> (status, body) of the FIRST call (co-23)
_lock = asyncio.Lock() # => guards _cache against concurrent replays (co-23)
@app.middleware("http") # => wraps every request (co-18)
async def idempotency(request: Request, call_next) -> Response: # => dedupes writes by key
key = request.headers.get("Idempotency-Key") # => the dedup key (co-18)
if request.method == "POST" and key is not None: # => only POST writes are deduped
async with _lock: # => the critical section -- no race on the cache (co-23)
cached = _cache.get(key) # => a prior result for this key?
if cached is not None: # => a replay -> return the cached first response
status, body = cached # => the original status + body
return Response(content=body, status_code=status, media_type="application/json") # => the SAME response (co-23)
response: Response = await call_next(request) # => first call -> run the handler
async with _lock: # => cache it under the lock
_cache[key] = (response.status_code, response.body) # => store for future replays (co-23)
return response # => the first-call response
return await call_next(request) # => non-POST or no key -> pass through unchanged
@app.post("/orders") # => a write route protected by idempotency
async def create_order() -> dict[str, int]: # => would create a row in a real service
return {"created": 1} # => a replayed key returns THIS cached body, not a second create (co-14)Run: uvicorn app:app --port 8000, then POST twice with the same Idempotency-Key header.
Key takeaway: an idempotency-key middleware caches the first response per key, so a replayed write returns the cached result instead of creating a duplicate -- the safe-retry property clients need on an unreliable network.
Why it matters: a client that retries a POST after a timeout risks creating two orders if the first
succeeded but the response was lost. An idempotency key makes the retry safe: same key, same result, no
duplicate. The lock is what keeps two concurrent replays of the same key from both reaching the handler.
Example 76: ETag and Conditional Responses
ex-76 · exercises co-17, co-14
The server stamps a response with an ETag (a content hash); a client that sends If-None-Match with a
matching ETag gets a 304 (empty body) instead of the full payload -- saving bandwidth on unchanged
resources.
learning/code/ex-76-etag-conditional-response/app.py
"""Example 76: ETag and Conditional Responses.
The server stamps a response with an ETag (a content hash); a client that sends If-None-Match with a matching
ETag gets a 304 (empty body) instead of the full payload -- saving bandwidth on unchanged resources.
Run: uvicorn app:app --port 8000, then: curl -i -H 'If-None-Match: "v1"' localhost:8000/doc. (co-17, co-14)
"""
import hashlib # => a stable content hash for the ETag (co-14)
from fastapi import FastAPI, Request, Response # => Request reads If-None-Match (co-17)
app = FastAPI() # => the ASGI application uvicorn serves
BODY = b'{"doc":"hello"}' # => the (fixed) resource body for this example
ETAG = '"' + hashlib.md5(BODY).hexdigest() + '"' # => a content-derived ETag (co-14)
@app.get("/doc") # => a route supporting conditional responses
async def doc(request: Request) -> Response: # => returns a Response so we can set headers/status directly
if request.headers.get("if-none-match") == ETAG: # => the client already has this exact version (co-17)
return Response(status_code=304, headers={"ETag": ETAG}) # => 304 Not Modified -- no body sent (co-17)
return Response(content=BODY, media_type="application/json", headers={"ETag": ETAG}) # => full payload + ETag (co-14)Run: uvicorn app:app --port 8000, then curl -i localhost:8000/doc (note the ETag), then
curl -i -H "If-None-Match: \"<that-etag>\"".
Output (conditional request with matching ETag):
HTTP/1.1 304 Not Modified
etag: "..."Key takeaway: an ETag + If-None-Match lets a client skip re-downloading an unchanged resource -- a
matching tag yields a bodyless 304.
Why it matters: for a resource that changes rarely (a config, a large catalog), conditional requests turn
every repeat fetch into a tiny 304 instead of the full payload. That is real bandwidth and real latency
saved, at the cost of one content-hash header.
Example 77: A uv Project from a Manifest
ex-77 · exercises co-07
A uv project declares its dependencies in pyproject.toml; uv sync reproduces the exact environment from
that file in one command, and uv run uvicorn main:app boots the service.
learning/code/ex-77-pyproject-uv-project/pyproject.toml
[project]
# Example 77: A uv project declared from a manifest (pyproject.toml).
# `uv sync` reads this file, creates the venv, and installs the exact pins below -- one reproducible command.
name = "async-fastapi-service"
version = "0.1.0"
description = "A uv-managed async FastAPI service (co-07)."
requires-python = ">=3.13"
dependencies = [
# => exact pins only -- the dependency-bump policy's Path A (co-07)
"fastapi==0.139.0",
"uvicorn==0.51.0",
"pydantic==2.11.0",
"aiosqlite==0.20.0",
]
[project.scripts]
# => `uv run serve` boots the ASGI server (co-07)
serve = "uvicorn:run"learning/code/ex-77-pyproject-uv-project/main.py
"""Example 77: A uv Project from a Manifest -- the runnable app.
A uv project declares its dependencies in pyproject.toml (the sibling manifest); `uv sync` reproduces the exact
environment from that file, and `uv run uvicorn main:app --port 8000` boots the service. (co-07)
"""
from fastapi import FastAPI # => the web framework pinned by the manifest (co-07)
app = FastAPI(title="uv Project Service") # => the ASGI application uvicorn serves
@app.get("/") # => a health-style route
def read_root() -> dict[str, str]: # => a minimal handler
return {"msg": "managed by uv"} # => confirms the uv-managed service is up (co-14)Run: uv sync && uv run uvicorn main:app --port 8000, then curl localhost:8000/.
Output:
{"msg":"managed by uv"}Key takeaway: a pyproject.toml manifest + uv sync reproduces the exact environment from one file -- the
project-level generalization of Example 9's one-off uv pip install.
Why it matters: Example 9 created an environment ad hoc; a real project declares its dependencies in a
committed manifest so CI, teammates, and deploys all reproduce the same environment. uv sync is the one
command that turns that manifest into a ready-to-run environment.
Example 78: A Dockerfile for Multi Worker ASGI Deploy
ex-78 · exercises co-24
A slim Dockerfile that builds the app into a reproducible image and runs uvicorn with multiple workers -- one
ASGI process per core, behind one port -- the production deploy shape.
learning/code/ex-78-docker-asgi-deploy/Dockerfile
# Example 78: A Dockerfile for Multi-Worker ASGI Deploy
# syntax=docker/dockerfile:1
# => a slim, reproducible production image running uvicorn with N workers behind the ASGI app (co-24)
# => build stage uses the pinned python base -- reproducible, slim
FROM python:3.13-slim AS base
# => set working directory inside the image
WORKDIR /app
# => install the exact runtime pins (co-07) -- uvicorn[standard] adds uvloop/httptools for production speed
RUN pip install --no-cache-dir \
"fastapi==0.139.0" \
"uvicorn[standard]==0.51.0" \
"pydantic==2.11.0" \
"aiosqlite==0.20.0"
# => copy the application code into the image
COPY main.py .
# => expose the port uvicorn listens on
EXPOSE 8000
# => run uvicorn with MULTIPLE workers -- one process per core, behind one port (co-24)
# => --workers 4 runs 4 ASGI processes; each owns its own event loop (co-02, co-24)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]learning/code/ex-78-docker-asgi-deploy/main.py
"""Example 78: A Dockerfile for Multi-Worker ASGI Deploy -- the runnable app.
The sibling Dockerfile builds this app into a slim image and runs uvicorn with multiple workers -- one ASGI
process per core. Build: docker build -t async-fastapi . Run: docker run -p 8000:8000 async-fastapi. (co-24)
"""
from fastapi import FastAPI # => the web framework the Dockerfile pins (co-24)
app = FastAPI(title="Deployable Service") # => the ASGI application the image serves
@app.get("/") # => a health-style route
def read_root() -> dict[str, str]: # => a minimal handler
return {"msg": "deployable"} # => confirms the containerised service is up (co-14)
@app.get("/health") # => a liveness probe the orchestrator polls (co-24)
def health() -> dict[str, str]: # => no dependencies -> always 200
return {"status": "ok"} # => a container orchestrator reads this to decide routing (co-24)Run: docker build -t async-fastapi . && docker run -p 8000:8000 async-fastapi, then
curl localhost:8000/health.
Output:
{"status":"ok"}Key takeaway: a multi-worker uvicorn deploy is one image, N ASGI processes (one per core), one port --
the production shape that turns the runnable service into a deployable one.
Why it matters: a single-process uvicorn (every example so far) uses one core; a multi-worker deploy
uses them all, behind one port, each with its own event loop. The /health endpoint is what an orchestrator
(Kubernetes, Nomad) polls to decide whether to route traffic to a container -- the last ingredient that makes
the service operable, not just runnable.
← Previous: Intermediate Examples · Next: Capstone →
Last updated July 28, 2026