Module 05: Request Context
Why this matters
In module 04 your auth middleware did something quietly important: it verified
a token and wrote request.state.user = user so that later code — the
handler, a service, a logger — could find out who's making the request without
re-verifying the token. That little stash is request-scoped state, and
this module is about doing it deliberately and well. Every request is a
short-lived world of its own: it has its authenticated user, its trace ID,
its deadline, its set of permission checks. That per-request world needs to
be reachable from many layers without every function taking twelve parameters
or, worse, reaching into a global variable that gets clobbered by the next
concurrent request.
The naive alternatives are both bad. Threading everything through
parameters (def create_order(body, current_user, request_id, db, trace_id, ...)) couples every function to every piece of context and makes signatures
unbearable. Global variables are catastrophic under concurrency: your
server handles many requests at once, and a module-level current_user
written by request A gets overwritten by request B a millisecond later —
request A now acts as the wrong person. Request context is the disciplined
middle path: a per-request container that the framework scopes correctly and
that layers can read from without tight coupling.
FastAPI's answer is dependency injection via Depends(...), and it's one
of the framework's best features. Instead of a handler reaching out to grab
context, the context is injected into the handler (and into services)
declaratively. current_user: User = Depends(get_current_user) says "before
you run me, resolve the current user and hand it to me." This inverts the
coupling: your handler declares what it needs, and FastAPI wires it up. This
module teaches the request lifecycle, what belongs in context (and what
absolutely doesn't), timeouts and cancellation, and the DI patterns that make
it all clean.
Concepts
The lifecycle of a request, and what "request-scoped" means
A request is born when bytes arrive, and it dies when the response is sent. "Request-scoped" state lives exactly that long: created during this request, readable by anything handling this request, and gone when it ends — critically, not shared with any other concurrent request. The lifecycle, layer by layer:
arrive → [middleware pre-request] → [dependencies resolve] → [handler] →
[dependencies clean up] → [middleware post-response] → sent
Middleware (module 03/04) can seed context early (request ID, user). Dependencies resolve just before the handler and can also produce context (load the user, open a DB session). The handler and any services it calls read that context. Then cleanup runs (close the DB session) and the response goes out. Everything in that window is one isolated request-world.
GLOBAL (app-scoped) REQUEST-SCOPED (isolated)
┌─────────────────┐ req A ─► [ user=A, id=A1, db=A ] ─► sent
│ CURRENT_USER = ?│ ◄─ A & B req B ─► [ user=B, id=B7, db=B ] ─► sent
└─────────────────┘ clobber each request its own world;
B overwrites A's value B can never see A's user
What belongs in request context
Keep it lightweight and request-specific:
- Request metadata: method, path, client IP, user agent — mostly already
on
request. - Identity: the authenticated user / session, injected by auth (middleware or a dependency).
- Trace / request IDs: a correlation id threaded through logs and downstream calls so you can reconstruct one request's whole journey.
- Derived request-scoped data: the result of a permission check, a resolved tenant/organization, a per-request DB session or transaction.
What does not belong: application-wide config (that's app-scoped, set once at startup), caches meant to outlive the request, or anything you're tempted to use as a general "pass data between unrelated functions" bag. Context is not a global dumping ground; over-relying on it to smuggle data around hides real dependencies and makes code hard to follow.
request.state vs. dependency injection
Two ways to carry context, and they compose:
request.state— an arbitrary attribute bag on the request object. Middleware writes to it (request.state.request_id = rid); anything with access torequestreads it. Good for values middleware produces before routing. Downside: it's untyped and only reachable where you have the rawrequest.- Dependency injection (
Depends) — a function whose return value is injected into handlers (and other dependencies) by declaring it as a parameter. Typed, testable, reusable, and it shows up in your OpenAPI docs. This is the idiomatic FastAPI way to derive and inject context.
from fastapi import Depends, Request, HTTPException
def get_current_user(request: Request):
user = getattr(request.state, "user", None) # seeded by auth middleware
if user is None:
raise HTTPException(status_code=401, detail="unauthorized")
return user
@app.get("/me")
async def me(current_user=Depends(get_current_user)):
return {"user": current_user}
The handler never reaches for the user; it declares it needs one, and
get_current_user provides it. That's dependency injection: the coupling is
inverted, and get_current_user is reusable across every route.
Dependencies that produce and clean up context (yield)
Some context needs setup and teardown — a database session must be opened
before the handler and closed after, even if the handler raises. FastAPI
dependencies support this with yield:
def get_db():
db = SessionLocal() # setup: runs before the handler
try:
yield db # the handler runs with `db` injected here
finally:
db.close() # teardown: always runs after, even on error
@app.post("/orders")
async def create_order(body: OrderIn, db=Depends(get_db)):
... # uses db; it's closed for you when the request ends
This is the clean answer to "acquire a resource for the duration of a request
and release it no matter what." The finally guarantees cleanup — the "clean
up after the request" best practice, enforced by the framework.
Sharing context across layers without tight coupling
The power move: dependencies can depend on other dependencies. A permission check depends on the current user; a service depends on the DB session and the user. You compose them, and FastAPI resolves the whole graph per request:
def require_admin(user=Depends(get_current_user)):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="admin only")
return user
@app.delete("/users/{user_id}")
async def delete_user(user_id: int, admin=Depends(require_admin), db=Depends(get_db)):
...
┌─ Depends(require_admin) ─► Depends(get_current_user)
delete_user handler ┤ │
└─ Depends(get_db) ▼
reads request.state.user
FastAPI resolves this graph per request (get_current_user cached, runs once)
delete_user doesn't know how the admin check works or where the user
came from — it declares "I need an admin and a db." The layers are decoupled:
you can change how users are loaded or how admin is determined without
touching a single handler. That's sharing context without coupling.
Timeouts and cancellation
A request shouldn't run forever. If a client disconnects, or a downstream call
hangs, you want to cancel the work rather than tie up a worker. In async
Python this rides on asyncio cancellation and asyncio.timeout:
import asyncio
from fastapi import HTTPException
@app.get("/slow")
async def slow():
try:
async with asyncio.timeout(2.0): # cancel if it takes > 2s
await do_expensive_thing()
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="upstream timed out")
return {"ok": True}
FastAPI/Starlette also propagate client-disconnect as cancellation to your
await points — an awaited call gets cancelled if the client goes away. The
concept to internalize: context isn't only data, it's also the request's
deadline and cancellation signal. Long-running handlers should be
cancellable and bounded, not open-ended.
Best practices
- Keep context lightweight. Small, request-specific values. Not a cache, not app config, not a junk drawer.
- Clean up after the request. Use
yielddependencies withfinally(or middleware post-response) to release anything you acquired. - Don't over-rely on context to pass data. If function B needs a value function A computed, prefer passing it explicitly or via a dependency, not stuffing it into a shared bag to smuggle across unrelated code. Hidden data-flow through context is a maintenance trap.
- Never use module-level globals for per-request data. They're shared
across concurrent requests and will leak one user's data into another's.
Use
request.state,Depends, orcontextvars(advanced) — all of which are correctly request-scoped.
Command reference
| Pattern | What it does | Example |
|---|---|---|
request.state.x = v |
Middleware writes request-scoped data | request.state.request_id = rid |
getattr(request.state, "x", None) |
Safely read possibly-unset state | in a dependency |
param=Depends(fn) |
Inject fn's return value into a handler |
user=Depends(get_current_user) |
dependency with yield |
Setup + guaranteed teardown per request | get_db above |
| dependency-on-dependency | Compose context without coupling | require_admin depends on get_current_user |
dependencies=[Depends(fn)] |
Run a dependency for its side effect (no injection) | @app.get("/x", dependencies=[Depends(rate_check)]) |
asyncio.timeout(seconds) |
Bound a block; cancel on overrun | async with asyncio.timeout(2.0): |
contextvars.ContextVar |
Request-safe implicit context (advanced) | for logging correlation |
Depends at three levels. You can attach a dependency to a single
parameter (inject its value), to a path operation via
dependencies=[...] (run it for validation/side effects without using the
return), or to an entire router/app via APIRouter(dependencies=[...]) /
FastAPI(dependencies=[...]) (apply to every route beneath it). Router-level
dependencies are how you protect a whole group of endpoints with one line —
the DI counterpart to auth middleware, but scoped to specific routes.
Dependencies are cached per request. If two dependencies both depend on
get_current_user, FastAPI calls it once per request and reuses the result
— so composing dependencies doesn't multiply work. (Disable with
Depends(fn, use_cache=False) when you truly need a fresh call.)
request.state is not typed or autocompleted. It's a plain attribute bag,
so a typo (request.state.reqest_id) fails silently as AttributeError or a
wrong None. Prefer deriving typed values through dependencies where it
matters; use state mainly as the middleware→dependency hand-off.
Hands-on exercises
Continue in the api-layer project.
1. Read middleware-seeded state in a dependency
Keep the request-ID middleware from module 03. Add:
from fastapi import Depends, Request
def get_request_id(request: Request) -> str:
return getattr(request.state, "request_id", "unknown")
@app.get("/ctx")
async def ctx(request_id: str = Depends(get_request_id)):
return {"request_id": request_id}
Call GET /ctx. Expected: the injected request_id matches the
X-Request-ID response header. The handler got the value without touching
request directly — decoupled.
2. Inject the current user
from fastapi import Depends, Request, HTTPException
def get_current_user(request: Request):
user = getattr(request.state, "user", None)
if user is None:
raise HTTPException(status_code=401, detail="unauthorized")
return user
@app.get("/me")
async def me(current_user=Depends(get_current_user)):
return {"user": current_user}
With the auth middleware from module 04 active, call /me with
Authorization: Bearer secret (returns the user) and without (401). The
dependency turns middleware-seeded state into a clean, reusable injection.
3. A yield dependency with guaranteed cleanup
Simulate a DB session so you can see setup and teardown:
class FakeSession:
def __init__(self): print("[db] open")
def close(self): print("[db] close")
def get_db():
db = FakeSession()
try:
yield db
finally:
db.close()
@app.get("/with-db")
async def with_db(db=Depends(get_db)):
print("[handler] using db")
return {"ok": True}
Call /with-db and watch the console. Expected order: [db] open →
[handler] using db → [db] close. Now make the handler raise RuntimeError("boom") and confirm [db] close still prints — teardown runs
even on error.
4. Compose dependencies (permission check)
def require_admin(user=Depends(get_current_user)):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="admin only")
return user
@app.delete("/admin/things/{thing_id}")
async def delete_thing(thing_id: int, admin=Depends(require_admin)):
return {"deleted": thing_id, "by": admin["name"]}
Make verify_token (module 04) return a user without is_admin for one
token and with it for another. Call the endpoint with each. Expected:
non-admin → 403, admin → 200. delete_thing never checks admin-ness
itself — it declared the requirement.
5. Protect a whole router with one dependency
from fastapi import APIRouter, Depends
admin_router = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])
@admin_router.get("/stats")
async def stats():
return {"secret": "numbers"}
@admin_router.get("/audit")
async def audit():
return {"secret": "log"}
app.include_router(admin_router)
Call /admin/stats and /admin/audit as non-admin (both 403) and admin
(both 200). Expected: one line protected two endpoints — router-level DI is
the per-route counterpart to global auth middleware.
6. Prove dependency caching
def noisy_user(request: Request):
print("[dep] resolving user")
return getattr(request.state, "user", {"name": "anon"})
def a(u=Depends(noisy_user)): return u
def b(u=Depends(noisy_user)): return u
@app.get("/cache-test")
async def cache_test(x=Depends(a), y=Depends(b)):
return {"x": x, "y": y}
Call /cache-test. Expected: [dep] resolving user prints once, not
twice, even though two dependencies both need it — FastAPI caches per request.
7. Bound a slow handler with a timeout
import asyncio
from fastapi import HTTPException
async def do_expensive_thing(seconds: float):
await asyncio.sleep(seconds)
@app.get("/timeout-demo")
async def timeout_demo(seconds: float = 1.0):
try:
async with asyncio.timeout(2.0):
await do_expensive_thing(seconds)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="operation timed out")
return {"slept": seconds}
Call ?seconds=1 (completes, 200) and ?seconds=5 (504 after ~2s).
Expected: the second is cancelled at the 2-second deadline rather than running
the full 5 seconds. The request's deadline is part of its context.
8. Diagnose and fix: a concurrency-unsafe global
This code "works" in local testing with one request at a time but corrupts data under concurrent load — user A sometimes sees user B's name. Find the root cause and fix it using request context properly.
from fastapi import FastAPI, Request
app = FastAPI()
CURRENT_USER = {} # (!) module-level global
@app.middleware("http")
async def load_user(request: Request, call_next):
global CURRENT_USER
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
CURRENT_USER = {"name": token or "anon"} # (!) writes to shared global
return await call_next(request)
@app.get("/name")
async def name():
return {"name": CURRENT_USER["name"]} # (!) reads the shared global
Solution
The bug is the module-level CURRENT_USER global. The server handles many
requests concurrently; request B overwrites CURRENT_USER between the moment
request A's middleware set it and the moment A's handler reads it. A then
returns B's name. Globals are app-scoped, not request-scoped — exactly the
wrong lifetime for per-request data.
Fix: put the value on request.state (request-scoped, isolated per request)
and read it via injection, never a global:
@app.middleware("http")
async def load_user(request: Request, call_next):
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
request.state.user = {"name": token or "anon"} # per-request, isolated
return await call_next(request)
def get_user(request: Request):
return getattr(request.state, "user", {"name": "anon"})
@app.get("/name")
async def name(user=Depends(get_user)):
return {"name": user["name"]}
Lesson: per-request data must live in per-request storage (request.state,
Depends, or contextvars) — never a module global, which is shared across
all concurrent requests and will leak one user's data into another's.
Independent challenge
No code given. Build a small "current organization" context system for a
multi-tenant API. Every request carries an X-Org-ID header; a dependency
must resolve it into an org object (rejecting unknown orgs with 404), and a
second dependency must verify the authenticated user (from module 04's
auth) actually belongs to that org (rejecting with 403 otherwise). Then
write two protected endpoints that both need the org and a per-request "DB
session" (a yield dependency), and confirm the session is opened once and
closed once per request even when a permission check rejects the request
early. Reuse the dependency-caching behavior from this module so the org
is resolved only once no matter how many dependencies need it, and reuse
module 04's idea that this is the per-route counterpart to auth
middleware.
Hint
Compose: get_org depends on nothing but the header; require_org_member
depends on both get_current_user and get_org. Attach
require_org_member at the router level so both endpoints are protected by
one line. For the "closed even on early rejection" guarantee, make sure the
get_db yield/finally teardown runs — dependencies with yield clean up
even when a later dependency or the handler raises, but a dependency that
raises before get_db resolves means get_db never opened, which is also
fine. Order your dependencies=[...] so the cheap checks fail first.
Common mistakes & troubleshooting
- Module-level globals for per-request data. Shared across concurrent
requests; leaks one user's data into another's. Use
request.state/Depends/contextvars. - Threading context through every function parameter. Couples everything
and bloats signatures. Inject via
Dependsinstead. - Forgetting cleanup. Resources acquired per request (DB sessions, files)
must be released. Use a
yielddependency withfinally. - Using context as a junk drawer. Stuffing unrelated data into
request.stateto smuggle it between functions hides real dependencies. Keep context lightweight and pass data explicitly where you can. - Typos in
request.state. It's untyped;request.state.reqest_idfails silently. Prefer typed dependencies for values that matter. - Unbounded handlers. A handler with no timeout on a downstream call can
tie up a worker forever. Bound long operations with
asyncio.timeoutand make them cancellable. - Assuming a dependency runs multiple times. It's cached per request by
default; if you truly need a fresh call, pass
use_cache=False.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What does "request-scoped" mean, and why is a module-level global the wrong place to store the current user?
- Name three things that belong in request context and one thing that does not.
- What's the difference between
request.stateand aDependsdependency for carrying context, and when do you use each? - Why does a dependency written with
yieldguarantee cleanup even when the handler raises? - How does dependency composition let a handler require an admin user without the handler knowing how admin-ness is determined?
- If two dependencies both depend on
get_current_user, how many times does it run per request, and why does that matter? - What are the two things "request context" includes besides data — i.e., what does a request's deadline/cancellation have to do with context?
Answers
- It means the data lives only for the duration of one request and is isolated from other concurrent requests. A module-level global is app-scoped and shared across all requests running at once, so a concurrent request overwrites it — one user ends up seeing another's data.
- Belongs: authenticated user/session, trace/request ID, per-request DB session, resolved tenant, request metadata, a permission-check result. Does not: application-wide config, long-lived caches, or arbitrary data used to smuggle values between unrelated functions.
request.stateis an untyped attribute bag written by middleware and read wherever you have the rawrequest— good for the middleware→handler hand-off.Dependsinjects a typed, testable, reusable value and appears in OpenAPI — the idiomatic way to derive and inject context. Use state for middleware-produced values, DI for derived/typed context.- The code after
yieldruns in afinally, and FastAPI ensures the generator is resumed (cleanup executed) after the response is produced, whether the handler returned normally or raised — so teardown always runs. - The handler declares
admin=Depends(require_admin).require_admindepends onget_current_userand does the check, raising403on failure. The handler only expresses the requirement; the how lives in the dependency, so it's decoupled and reusable. - Once — dependencies are cached per request, so a shared dependency is resolved a single time and reused. It matters because composing many dependencies that share a common one doesn't multiply work (e.g. no repeated token verification or DB lookups).
- Its identity/data and its deadline and cancellation signal. A request
has a bounded lifetime; long operations should be cancellable and
time-bounded (
asyncio.timeout, client-disconnect propagation), so context includes "how long may this run / has it been cancelled," not just "who is it."
Further reading & sources
- FastAPI — Dependencies - the core
Depends(...)model for injecting request-scoped context declaratively. - FastAPI — Dependencies with yield - setup/teardown dependencies that guarantee cleanup (e.g. DB sessions) even when a handler raises.
- FastAPI — Global Dependencies - attaching dependencies at the router/app level to protect whole groups of routes.
- Starlette — Requests (
request.state) - the per-request attribute bag used for the middleware-to-handler hand-off. - Python docs —
contextvars- request-safe implicit context that avoids the concurrency hazards of module-level globals. - Python docs —
asyncio.timeout- bounding and cancelling long-running work, the deadline side of request context.
Free PDF notes
Download the notes for each video on this page as a printable PDF, with every diagram and interview questions: SD-B-020: Network Timeouts, SD-C-019: API Timeouts & Deadline Propagation. Each section in one file: Phase 2, Networking (PDF) and Phase 2, APIs (PDF).
Next
06-handlers-controllers-and-services — you can validate, wrap, and inject context; now you'll organize the actual business logic into clean layers (handler → controller → service) so it stays testable and DRY as the app grows.