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:
importthreadingimportcontextvars_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() orgetattr(_in_interceptor_thread, "flag", False)
def_enter_interceptor():
token=_in_interceptor_task.set(True)
_in_interceptor_thread.flag=Truereturntokendef_exit_interceptor(token):
_in_interceptor_task.reset(token)
_in_interceptor_thread.flag=FalseApply 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.
Python interceptor reentrancy guard is task-local, not thread-local
Severity: High
Affected repos:
middleware-pythonComponent boundary: middleware-python interceptor
Symptom
_interceptor.pyusescontextvars.ContextVar(_in_interceptor) as the reentrancy guard. ContextVars are scoped to the currentasynciotask. 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
requestslibrary 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.Impact
Fix recommendation
Use both — a contextvar for async tasks and a
threading.localfor thread-bound calls:Apply to every patch wrapper. Same pattern is unnecessary in the Node SDK because Node is single-threaded for user code.
Verification
asyncio.run(asyncio.gather(*[asyncio.to_thread(requests.get, url) for _ in range(10)]))and assert event count equals 10, not >10.