Tracer Version(s)
4.5.4 (also tested with 4.6.1 — same issue)
Python Version(s)
3.14.2
Pip Version(s)
uv 0.7.x (using uv instead of pip)
Bug Report
ddtrace's asyncio integration wraps coroutines passed to asyncio.BaseEventLoop.create_task() with a traced_coro wrapper in ddtrace/contrib/internal/asyncio/patch.py. This extra coroutine frame breaks anyio's CancelScope handling, causing CancelledError to leak out of task groups during normal operation.
The most visible symptom: first HTTP request via httpx returns 500 after every deployment, because httpx → httpcore → anyio connect_tcp() uses the Happy Eyeballs algorithm (RFC 6555) which races connections in a task group and cancels losers via cancel scope.
How it happens
- httpx makes a request → httpcore calls
anyio.connect_tcp()
connect_tcp() resolves DNS, gets multiple addresses (IPv4/IPv6 or multiple A records)
- Happy eyeballs spawns connection attempts via
tg.start_soon() → internally calls asyncio.create_task()
- ddtrace's
_wrapped_create_task wraps each coroutine in traced_coro (adds extra coroutine frame)
- When the first connection wins,
tg.cancel_scope.cancel() cancels the losers
- The
CancelledError does not propagate cleanly through the traced_coro wrapper — it leaks out of the task group
- Error propagates up through middleware → 500
Subsequent requests work because httpx reuses pooled connections, bypassing connect_tcp().
The wrapping code causing the issue
# ddtrace/contrib/internal/asyncio/patch.py, line 62-66
async def traced_coro(*args_c, **kwargs_c):
if dd_active != tracer.current_trace_context():
tracer.context_provider.activate(dd_active)
core.dispatch("asyncio.execute_task", (task_data,))
return await coro
This wrapper adds an extra coroutine frame between the original coroutine and the task scheduler. When anyio cancels a task via cancel scope, the CancelledError doesn't propagate correctly through this extra frame.
Reproduction
Reliably reproduces on ECS (Linux, dual-stack VPC networking where DNS returns multiple addresses). Hard to reproduce locally due to Docker Desktop not providing real dual-stack DNS.
Confirmed fixes:
- Removing
ddtrace-run entirely → fixed (no create_task wrapping)
- Removing the
traced_coro wrapper from _wrapped_create_task → fixed (keeping ddtrace patch active but not wrapping coroutines)
- Replacing httpx with aiohttp → fixed (aiohttp uses its own TCP stack, no anyio
connect_tcp)
- Disabling happy eyeballs in anyio
connect_tcp() → fixed (no task group, no cancel scopes)
Did not fix:
DD_PATCH_MODULES=httpx:false — the httpx patch is not the culprit
- Upgrading ddtrace to 4.6.1
- Vendoring anyio with the
CancelScope.__exit__ fix from anyio PR #1092
Impact
This affects any code path where asyncio.create_task() is called internally by a library that then cancels tasks as part of normal operation. Beyond anyio/httpx, this could affect asyncio.TaskGroup with cancellation, timeout patterns, and connection pools.
Suggested Fix
Consider one of:
- Skip wrapping internal library coroutines — detect coroutines from anyio/httpcore and pass through
- Use
contextvars for trace propagation instead of wrapping the coroutine in an extra async def
- Ensure
CancelledError is fully transparent through traced_coro
Related Issues
Tracer Version(s)
4.5.4 (also tested with 4.6.1 — same issue)
Python Version(s)
3.14.2
Pip Version(s)
uv 0.7.x (using uv instead of pip)
Bug Report
ddtrace's asyncio integration wraps coroutines passed to
asyncio.BaseEventLoop.create_task()with atraced_corowrapper inddtrace/contrib/internal/asyncio/patch.py. This extra coroutine frame breaks anyio'sCancelScopehandling, causingCancelledErrorto leak out of task groups during normal operation.The most visible symptom: first HTTP request via httpx returns 500 after every deployment, because httpx → httpcore → anyio
connect_tcp()uses the Happy Eyeballs algorithm (RFC 6555) which races connections in a task group and cancels losers via cancel scope.How it happens
anyio.connect_tcp()connect_tcp()resolves DNS, gets multiple addresses (IPv4/IPv6 or multiple A records)tg.start_soon()→ internally callsasyncio.create_task()_wrapped_create_taskwraps each coroutine intraced_coro(adds extra coroutine frame)tg.cancel_scope.cancel()cancels the losersCancelledErrordoes not propagate cleanly through thetraced_corowrapper — it leaks out of the task groupSubsequent requests work because httpx reuses pooled connections, bypassing
connect_tcp().The wrapping code causing the issue
This wrapper adds an extra coroutine frame between the original coroutine and the task scheduler. When anyio cancels a task via cancel scope, the
CancelledErrordoesn't propagate correctly through this extra frame.Reproduction
Reliably reproduces on ECS (Linux, dual-stack VPC networking where DNS returns multiple addresses). Hard to reproduce locally due to Docker Desktop not providing real dual-stack DNS.
Confirmed fixes:
ddtrace-runentirely → fixed (nocreate_taskwrapping)traced_corowrapper from_wrapped_create_task→ fixed (keeping ddtrace patch active but not wrapping coroutines)connect_tcp)connect_tcp()→ fixed (no task group, no cancel scopes)Did not fix:
DD_PATCH_MODULES=httpx:false— the httpx patch is not the culpritCancelScope.__exit__fix from anyio PR #1092Impact
This affects any code path where
asyncio.create_task()is called internally by a library that then cancels tasks as part of normal operation. Beyond anyio/httpx, this could affectasyncio.TaskGroupwith cancellation, timeout patterns, and connection pools.Suggested Fix
Consider one of:
contextvarsfor trace propagation instead of wrapping the coroutine in an extraasync defCancelledErroris fully transparent throughtraced_coroRelated Issues
CancelledErrorissue but gevent-specificCancelScopefiltering issue (fixing it did NOT resolve this bug)