5.8 KiB
| title | aliases | tags | sources | created | updated | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Python asyncio — ContextVar Does Not Propagate Across Task Boundaries |
|
|
|
2026-04-27 | 2026-04-27 |
Python asyncio — ContextVar Does Not Propagate Across Task Boundaries
Python's contextvars.ContextVar is copied at task creation time — the copy is a snapshot. Changes made to a ContextVar in a parent coroutine after a child task is spawned are NOT visible to the child. More critically, asyncio.wait_for(), asyncio.create_task(), and asyncio.ensure_future() all create a new execution context — any ContextVar set in the calling coroutine before spawning is visible (it was in the snapshot), but the pattern of setting the var in a middleware and reading it deep inside a task is fragile because the snapshot timing matters.
In practice, the failure mode is: a FastAPI middleware sets current_user_ctx.set(user), but a background task invoked via asyncio.wait_for() reads current_user_ctx.get() and gets the default value ("") instead. The bug is silent — no exception, just empty string in cost tracker events or missing audit logs.
Key Points
ContextVaris snapshot-based: when a task is created, it inherits a copy of the current context — changes to the original context after that point are invisibleasyncio.wait_for()creates a new task, so the same propagation rules apply — do NOT rely on ContextVar being readable insidewait_for-wrapped coroutines- The failure is silent:
ContextVar.get()returns the default value ("",None) without raising — the app continues working with empty/wrong user identity - The fix is always the same: pass the value as an explicit function parameter —
user_id: strall the way down the call chain - Applies to any ContextVar use: current user, request ID, tenant ID, tracing spans
Details
How the Bug Manifests
# ❌ BROKEN — ContextVar + asyncio.wait_for
_user_ctx: ContextVar[str] = ContextVar("user", default="")
async def process_request(user_id: str, prompt: str):
_user_ctx.set(user_id) # set in this coroutine's context
result = await asyncio.wait_for( # creates a new task
call_ai_with_tracking(prompt), # ← _user_ctx.get() == "" here
timeout=300,
)
return result
async def call_ai_with_tracking(prompt: str):
user = _user_ctx.get() # returns "" — the snapshot didn't include .set() above
await record(user_external_id=user, ...)
The Fix: Explicit Parameters
# ✅ CORRECT — explicit parameter
async def process_request(user_id: str, prompt: str):
result = await asyncio.wait_for(
call_ai_with_tracking(prompt, user_id=user_id), # pass explicitly
timeout=300,
)
return result
async def call_ai_with_tracking(prompt: str, user_id: str):
await record(user_external_id=user_id, ...) # always available
This pattern must be propagated down the entire call chain — every function between process_request and record() needs to accept and forward user_id.
When ContextVar DOES Work
ContextVar is reliable in the main request coroutine and synchronous call stack:
# ✅ WORKS — same coroutine, no task boundary
async def process_request(user_id: str):
_user_ctx.set(user_id)
result = await call_directly(prompt) # NOT wait_for, NOT create_task
return result
async def call_directly(prompt: str):
user = _user_ctx.get() # works — same context chain, no task creation
ContextVar also works for FastAPI request-scope middleware that sets a var, then reads it in the same request handler — as long as no background tasks are spawned within that request.
Task Boundaries That Break ContextVar
| API | Creates new context | ContextVar broken |
|---|---|---|
await coroutine() |
No | No |
asyncio.wait_for(coro, timeout) |
Yes | Yes |
asyncio.create_task(coro) |
Yes | Yes |
asyncio.ensure_future(coro) |
Yes | Yes |
asyncio.gather(*coros) |
Yes (each) | Yes |
loop.run_in_executor(fn) |
Yes | Yes |
Real Incident (2026-04-27)
NotebookLM (FastAPI backend): a ContextVar set in the route handler with set_user_ctx(user.email) was read inside asyncio.wait_for(generate_notebook(...), timeout=300). The _user ContextVar returned "" inside the task — cost tracker events were recorded with user_external_id="".
Fix: removed ContextVar entirely. Changed generate_notebook(prompt) to generate_notebook(prompt, user_external_id=user.email). All callers updated to pass the value explicitly.
LlamaIndex Note
LlamaIndex callbacks and event system also use ContextVar internally. If wrapping LlamaIndex calls with asyncio.wait_for, trace context may be lost. Additionally, extract_llama_tokens() helpers can return (0, 0) — always add a fallback:
input_tok, output_tok = extract_llama_tokens(response) or (len(prompt) // 4, 200)
Related Concepts
- wiki/tech-patterns/cost-tracker-integration — cost tracker integration where this bug was discovered; Step 4 documents the explicit-parameter pattern
- wiki/concepts/preflight-record-pattern — the preflight/record calls that received empty
user_external_iddue to this bug - wiki/tech-patterns/python-ai-agents — Python AI agent patterns; explicit parameter passing applies throughout
Sources
- daily/2026-04-27.md — NotebookLM cost tracker integration:
_userContextVar returned""insideasyncio.wait_for-wrapped notebook generation; confirmed same issue in video-accessibility; fix was explicituser_external_idparameter throughout call chain