The Request Context
Five facts belong to a request and to no function in particular: who is calling, which request id this is, how much of the deadline is left, which trace this belongs to, and which tenant's data may be touched. The storage layer's logger needs the request id to make its line searchable. The Payrail client needs the deadline to set its timeout. The organizer query needs the tenant to add its WHERE clause. None of those functions takes the caller as an argument, and none of them should have to.
Passing the five facts as five extra parameters to every function is unreadable, and the first function that forgets to pass one breaks the chain. Putting them in a global is the mistake Topic 20 spent a page on. A request context, one immutable object created at the edge and carried by the async task, is how they travel. This topic is what goes in it, how it reaches the storage layer without being passed by hand, and what has to happen when the request hands work to the worker.
What Belongs in the Context
request_id, accepted from the client's X-Request-Id header or generated at the edge, as Chapter 2 specified. trace, the W3C trace context that Chapter 13 joins across services. principal, the authenticated caller that Chapter 5 resolves from the token. tenant_id, the organizer whose data this request may touch, which Chapter 5 enforces on every organizer query. deadline, the moment after which nothing this request does is worth finishing, which Chapter 7 sets per route and which every outbound call spends from. Five fields, and Stagedoor's context has exactly those five.
The test for whether a value belongs is whether it describes the call or the caller. The seat label is an argument to the rule: two calls from the same buyer in the same request could name two seats. The caller is context: it is the same for every function the request runs, at every depth. Nothing that is an argument to a business rule goes in, because a rule that reads its arguments from the context works only when called from the one route that set them, and Chapter 8's worker calls the same rule with no route at all.
Created at the Edge, Immutable After
The first middleware of Topic 22 reads or generates the request id, computes the deadline from the route's budget, extracts the trace, and creates the context with principal and tenant_id empty. The authentication middleware, three rings in, resolves the token and produces a new context with the principal filled, replacing the first. From that point the context is read-only. The object is a frozen dataclass, and the only way to change it is to construct a replacement, which only the two middlewares do.
A function that mutates the context mid-request is the bug where a log line carries the wrong user. Stagedoor's first version had a mutable context, and the admin impersonation feature set principal to the organizer being impersonated and did not set it back; every log line for the rest of that request, including the audit line that said who performed the action, named the wrong person. Immutability is not a style preference here. It is the reason an audit log can be believed.
contextvars
Python's mechanism for a value scoped to the current async task is contextvars. A ContextVar is set once, in middleware, and is readable anywhere below without a parameter. The value follows the task across every await, and each task started by the event loop gets its own copy of the context, so two requests interleaving on the same loop never see each other's value. The demonstrated example sets the context in the first middleware and reads the request id inside the storage layer's logger, four calls and two layers away, with no function in between mentioning it.
request_ctx: ContextVar[RequestContext] = ContextVar("request_ctx") @app.middleware("http") async def context_middleware(request, call_next): rid = request.headers.get("X-Request-Id") or str(uuid4()) ctx = RequestContext(request_id=rid, trace=parse_traceparent(request), deadline=clock.now() + budget_for(request.url.path), principal=None, tenant_id=None) token = request_ctx.set(ctx) try: response = await call_next(request) response.headers["X-Request-Id"] = rid return response finally: request_ctx.reset(token) # storage/log.py: two layers down, no parameter carried it here def log(event: str, **fields): ctx = request_ctx.get() logger.info(event, request_id=ctx.request_id, trace=ctx.trace.id, **fields)
The middleware does three things. It builds the context from the request, sets the variable and keeps the token, and resets the variable when the response is written so the next request on this task starts clean. The storage logger, which has no idea what a request is, reads the variable and stamps every line with the request id and the trace. Between the two there are the router, the handler, the domain function and the repository, and none of them has a ctx parameter. Under 3,000 requests a second, hundreds of these are in flight on one loop at once, and each one reads its own value, because the loop copies the context when it creates the task that runs the request.
Crossing Into the Worker
The context does not follow a job through Redis. A ContextVar lives in the process's memory and dies with the task; the job payload that the api writes to the stream is bytes, and the worker that claims it on worker-01 is a different process on a different host. Chapter 8's job payload therefore carries request_id and trace as explicit fields, written by the outbox publisher, and the worker rebuilds the context from them when it claims the job. The PDF job's log lines then carry the same request id as the checkout that queued it, and Chapter 13's search for one buyer's complaint finds the request, the job and the email in one query.
The same is true of every outbound call. The Payrail client forwards the trace in the traceparent header and the request id in X-Request-Id, so Payrail's support can find the charge from Stagedoor's id. Anything that leaves the process must carry the context's ids explicitly, because nothing outside the process can read a ContextVar. Marek's rule for the boundary: serialize the ids out, rebuild the context on the other side, and treat a job or a request without them as a bug in the sender.
The Deadline in the Context
The deadline is a timestamp, not a duration, because a duration would have to be recomputed at every layer. A checkout request enters with a 10-second budget and a deadline of now plus 10 seconds. It spends 130 milliseconds on the seat hold and 400 on Payrail's usual answer, and each of those calls set its own timeout to the smaller of its default and what remained on the context's deadline. When Payrail is slow and a retry is being considered with 400 milliseconds left, the retry gets a 400-millisecond timeout, not Payrail's 3-second default, and the buyer receives a clean 504 inside the budget instead of a response after the load balancer gave up on the connection.
Every outbound client in Stagedoor reads request_ctx.get().remaining() and never reads a timeout from its own configuration alone. The 3 seconds for Payrail, the 100 milliseconds for Redis and the 5 seconds for a Postgres statement are ceilings, and the deadline caps all of them. Chapter 7 is where the budgets are chosen and the arithmetic of retries against them is done; this topic is only where the number lives, which is the context, and how it gets to the client, which is by reading it there.
What the Context Is Not
Not a bag for passing parameters to avoid changing signatures. The moment event_id goes into the context so that a deep function can read it without a parameter, that function works only when called from the one route that set it, and the worker calling it with a fresh context gets None. Not a cache: the parsed request body, the seat map, a user lookup that three functions need, all of those are arguments or return values, and putting them in the context makes the context mutable and the functions untestable. Not mutable state of any kind. The five fields describe the caller, they are set at the edge, and they are read in cross-cutting code: logging, tracing, deadlines, tenancy. A sixth field that does not fit that sentence does not go in.
- The request id as a function parameter on everything — every signature in three layers grows by one, and the first function that forgets to pass it breaks the log chain for every line below it.
- A thread-local in an async service — the event loop runs hundreds of requests on one thread, so the value set by one request is read by the next one to resume on that thread, and the log line names the wrong buyer.
- Business parameters in the context —
event_idread from the context inside the storage layer, and a repository function that works from one route and returns nothing from the worker. - Losing the context at the worker boundary — a job payload with no request id, and a PDF that failed to render is unsearchable from the checkout that queued it, which is how the 40-minute delay of Chapter 8 stayed invisible.
- A mutable context — impersonation sets
principaland never sets it back, and the audit line for the rest of the request names the wrong person.
- Create the context in the first middleware, fill the principal in the auth middleware by constructing a replacement, and make the object frozen so nothing below can change it.
- Use
contextvarsfor the carry, reset the variable when the response is written, and read it only in cross-cutting code: logging, tracing, deadlines, tenancy. - Serialize
request_idandtraceinto every job payload and every outbound header, and rebuild the context on the other side before the first log line. - Keep the deadline in the context as a timestamp and derive every outbound timeout from what remains, with the client's own number as a ceiling only.
- Reject a sixth field unless it describes the caller rather than the call, and never let a business argument in to save a signature change.
context.Context the pattern's clearest form, with cancellation and the deadline built inJava ThreadLocal and Spring RequestContextHolder the thread-per-request version, wrong on a loopNode AsyncLocalStorage the same task-scoped carry for the event loopOpenTelemetry context propagation, which carries the trace across the same boundariesKnowledge Check
Which of these belongs in the request context rather than as a parameter, and by what test?
- The seat label, because every layer from route to SQL needs it and passing it is tedious
- The parsed request body, because the storage layer should never re-parse the bytes it was sent
- The seat map for the event, because three functions in the request read it and it is 2,000 rows
- The tenant id, because it describes who is calling and is the same for every function in the request
Hundreds of requests interleave on one event loop. How does a ContextVar keep one request's id from leaking into another's log lines?
- The variable is locked for the duration of each request, so only one request reads it at a time
- Each task gets its own copy of the context when the loop creates it, so values never cross
- The variable is bound to the thread, and each request runs on a thread of its own
- The middleware clears the variable before every await, so no other request can read a stale value
The api queues a PDF job to Redis. What must the job payload carry, and why?
- The principal's access token, so the worker can act with the buyer's own permissions
- A reference to the ContextVar, so the worker can read the same context object as the api
- The request id and trace as explicit fields, because the context does not cross processes
- The remaining deadline, so the worker abandons the render when the request would have timed out
A checkout has 400 ms left on its 10-second budget when the Payrail client is about to retry. What timeout does the retry get?
- 400 ms, the smaller of the client's 3-second ceiling and what remains
- 3 seconds, the client's configured timeout, because the retry is a fresh call
- 10 seconds, because a retry starts a new budget from the moment it is made
- 800 ms, because the checkout SLO says every response must return under that
You got correct