obsidian/wiki/concepts/asyncio-contextvar-task-boundary.md
2026-04-28 22:21:29 +01:00

5.8 KiB

title aliases tags sources created updated
Python asyncio — ContextVar Does Not Propagate Across Task Boundaries
asyncio-contextvar
python-contextvar-wait-for
contextvar-task-boundary
python
asyncio
concurrency
debugging
fastapi
gotcha
daily/2026-04-27.md
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

  • ContextVar is snapshot-based: when a task is created, it inherits a copy of the current context — changes to the original context after that point are invisible
  • asyncio.wait_for() creates a new task, so the same propagation rules apply — do NOT rely on ContextVar being readable inside wait_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: str all 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)

Sources

  • daily/2026-04-27.md — NotebookLM cost tracker integration: _user ContextVar returned "" inside asyncio.wait_for-wrapped notebook generation; confirmed same issue in video-accessibility; fix was explicit user_external_id parameter throughout call chain