Skip to content

Python interceptor reentrancy guard is task-local, not thread-local #15

Description

@AndresL230

Python interceptor reentrancy guard is task-local, not thread-local

Severity: High
Affected repos: middleware-python
Component boundary: middleware-python interceptor

Symptom

_interceptor.py uses contextvars.ContextVar (_in_interceptor) as the reentrancy guard. ContextVars are scoped to the current asyncio task. They do not propagate when work moves to a different OS thread (e.g., ThreadPoolExecutor, sync HTTP libraries called from inside async patches, libraries that internally chain HTTP across threads).

Concrete failure mode: an async httpx call is being intercepted (the contextvar is set). The httpx implementation, for some operations, dispatches sync urllib3 work to a thread pool. The thread pool worker has a fresh context — the contextvar is unset — so the urllib3 patch fires too, double-counting the call. Conversely, a requests library that internally retries via a separate thread can recurse through the patched urllib3 and not be guarded.

Evidence

  • middleware-python/recost/_interceptor.py_in_interceptor: contextvars.ContextVar[bool] = ContextVar(...); _in_interceptor.set(True) is called inside each patch wrapper.
  • Both sync (urllib3) and async (httpx) patches use the same guard.

Impact

  • Double-counted events in mixed sync/async stacks. Telemetry overstates request volume.
  • Possible infinite loop if the patched library does any internal thread hop that re-enters the patch.

Fix recommendation

Use both — a contextvar for async tasks and a threading.local for thread-bound calls:

import threading
import contextvars

_in_interceptor_task: contextvars.ContextVar[bool] = ContextVar("_in_interceptor_task", default=False)
_in_interceptor_thread = threading.local()

def _is_in_interceptor() -> bool:
    return _in_interceptor_task.get() or getattr(_in_interceptor_thread, "flag", False)

def _enter_interceptor():
    token = _in_interceptor_task.set(True)
    _in_interceptor_thread.flag = True
    return token

def _exit_interceptor(token):
    _in_interceptor_task.reset(token)
    _in_interceptor_thread.flag = False

Apply to every patch wrapper. Same pattern is unnecessary in the Node SDK because Node is single-threaded for user code.

Verification

  • Add a test: asyncio.run(asyncio.gather(*[asyncio.to_thread(requests.get, url) for _ in range(10)])) and assert event count equals 10, not >10.
  • Add a test that mixes httpx async + urllib3 sync in the same task, asserts exactly one event per outbound call.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions