Skip to content

feat: Add experimental async transport (port of PR #4572)#5646

Open
BYK wants to merge 24 commits intomasterfrom
feat/async-transport
Open

feat: Add experimental async transport (port of PR #4572)#5646
BYK wants to merge 24 commits intomasterfrom
feat/async-transport

Conversation

@BYK
Copy link
Member

@BYK BYK commented Mar 12, 2026

Add an experimental async transport using httpcore's async backend,
enabled via _experiments={"transport_async": True}.

This is a manual port of PR #4572 (originally merged into potel-base)
onto the current master branch.

Key changes

  • transport.py: Refactor BaseHttpTransport into HttpTransportCore
    (shared base) + BaseHttpTransport (sync) + AsyncHttpTransport
    (async, conditional on httpcore[asyncio]). Extract shared helpers:
    _handle_request_error, _handle_response, _update_headers,
    _prepare_envelope. Update make_transport() to detect the
    transport_async experiment.

  • worker.py: Add Worker ABC base class and AsyncWorker
    implementation using asyncio.Queue / asyncio.Task.

  • client.py: Add close_async() / flush_async() with async-vs-sync
    transport detection. Extract _close_components() / _flush_components().

  • api.py: Expose flush_async() as a public API.

  • integrations/asyncio.py: Patch loop.close to flush pending events
    before shutdown. Skip span wrapping for internal Sentry tasks.

  • utils.py: Add is_internal_task() / mark_sentry_task_internal()
    via ContextVar for async task filtering.

  • setup.py: Add "asyncio" extras_require (httpcore[asyncio]==1.*).

  • config.py / tox.ini: Widen anyio to >=3,<5 for httpx and FastAPI.

Notes

  • tox.ini was manually edited (the generation script requires a
    free-threaded Python interpreter). A full regeneration should be done
    before merge.

Refs: GH-4568

@github-actions
Copy link
Contributor

github-actions bot commented Mar 12, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

Langchain

  • Set gen_ai.operation.name and gen_ai.pipeline.name on LLM spans by ericapisani in #5849
  • Broaden AI provider detection beyond OpenAI and Anthropic by ericapisani in #5707
  • Update LLM span operation to gen_ai.generate_text by ericapisani in #5796

Other

Bug Fixes 🐛

Ci

  • Use gh CLI to convert PR to draft by stephanie-anderson in #5874
  • Use GitHub App token for draft PR enforcement by stephanie-anderson in #5871

Openai

  • Always set gen_ai.response.streaming for Responses by alexander-alderman-webb in #5697
  • Simplify Responses input handling by alexander-alderman-webb in #5695
  • Use max_output_tokens for Responses API by alexander-alderman-webb in #5693
  • Always set gen_ai.response.streaming for Completions by alexander-alderman-webb in #5692
  • Simplify Completions input handling by alexander-alderman-webb in #5690
  • Simplify embeddings input handling by alexander-alderman-webb in #5688

Other

  • (google-genai) Guard response extraction by alexander-alderman-webb in #5869
  • (workflow) Fix permission issue with github app and PR draft graphql endpoint by Jeffreyhung in #5887

Internal Changes 🔧

Langchain

  • Add text completion test by alexander-alderman-webb in #5740
  • Add tool execution test by alexander-alderman-webb in #5739
  • Add basic agent test with Responses call by alexander-alderman-webb in #5726
  • Replace mocks with httpx types by alexander-alderman-webb in #5724
  • Consolidate span origin assertion by alexander-alderman-webb in #5723
  • Consolidate available tools assertion by alexander-alderman-webb in #5721

Openai

  • Replace mocks with httpx types for streaming Responses by alexander-alderman-webb in #5882
  • Replace mocks with httpx types for streaming Completions by alexander-alderman-webb in #5879
  • Move input handling code into API-specific functions by alexander-alderman-webb in #5687

Other

  • (ai) Rename generate_text to text_completion by ericapisani in #5885
  • (asyncpg) Normalize query whitespace in integration by ericapisani in #5855
  • Exclude compromised litellm versions by alexander-alderman-webb in #5876
  • Reactivate litellm tests by alexander-alderman-webb in #5853
  • Add note to coordinate with assignee before PR submission by sentrivana in #5868
  • Temporarily stop running litellm tests by alexander-alderman-webb in #5851

Other

  • ci+docs: Add draft PR enforcement by stephanie-anderson in #5867

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 12, 2026

Codecov Results 📊

13 passed | Total: 13 | Pass Rate: 100% | Execution Time: 9.71s

All tests are passing successfully.

❌ Patch coverage is 22.02%. Project has 14674 uncovered lines.

Files with missing lines (7)
File Patch % Lines
utils.py 52.65% ⚠️ 437 Missing and 79 partials
transport.py 24.53% ⚠️ 403 Missing and 5 partials
client.py 54.09% ⚠️ 247 Missing and 56 partials
worker.py 22.22% ⚠️ 168 Missing
asyncio.py 0.00% ⚠️ 113 Missing
api.py 63.58% ⚠️ 59 Missing
consts.py 99.43% ⚠️ 2 Missing

Generated by Codecov Action

@github-actions
Copy link
Contributor

Codecov Results 📊


Generated by Codecov Action

Add an experimental async transport using httpcore's async backend,
enabled via `_experiments={"transport_async": True}`.

This is a manual port of PR #4572 (originally merged into `potel-base`)
onto the current `master` branch.

Key changes:
- Refactor `BaseHttpTransport` into `HttpTransportCore` (shared base) +
  `BaseHttpTransport` (sync) + `AsyncHttpTransport` (async, conditional
  on httpcore[asyncio])
- Add `Worker` ABC and `AsyncWorker` using asyncio.Queue/Task
- Add `close_async()` / `flush_async()` to client and public API
- Patch `loop.close` in asyncio integration to flush before shutdown
- Add `is_internal_task()` ContextVar to skip wrapping Sentry-internal tasks
- Add `asyncio` extras_require (`httpcore[asyncio]==1.*`)
- Widen anyio constraint to `>=3,<5` for httpx and FastAPI

Refs: GH-4568

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@BYK BYK force-pushed the feat/async-transport branch from 8c808bf to 4f8a00c Compare March 12, 2026 15:45
The base class _make_pool returns a union of sync and async pool types,
so mypy sees _pool.request() as possibly returning a non-awaitable.
Add type: ignore[misc] since within AsyncHttpTransport the pool is
always an async type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BYK and others added 2 commits March 12, 2026 16:12
The asyncio extra on httpcore pulls in anyio, which conflicts with
starlette's anyio<4.0.0 pin and causes pip to downgrade httpcore to
0.18.0. That old version crashes on Python 3.14 due to typing.Union
not having __module__.

Keep httpcore[http2] in requirements-testing.txt (shared by all envs)
and add httpcore[asyncio] only to linters, mypy, and common envs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- AsyncWorker.kill() now calls self._task.cancel() before clearing the
  reference, preventing duplicate consumers if submit() is called later
- close() with AsyncHttpTransport now does best-effort sync cleanup
  (kill transport, close components) instead of silently returning
- flush()/close() log warnings instead of debug when async transport used
- Add __aenter__/__aexit__ to _Client for 'async with' support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Asyncio and gevent don't mix — async tests using asyncio.run() fail
under gevent's monkey-patching. Add skip_under_gevent decorator to
all async tests in test_transport.py and test_client.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Python 3.6 doesn't support PEP 563 (from __future__ import annotations).
Use string-quoted annotations instead, matching the convention used in
the rest of the SDK.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BYK and others added 5 commits March 16, 2026 15:29
Add 77 tests covering:
- AsyncWorker lifecycle (init, start, kill, submit, flush, is_alive)
- AsyncWorker edge cases (no loop, queue full, cancelled tasks, pid mismatch)
- HttpTransportCore methods (_handle_request_error, _handle_response,
  _update_headers, _prepare_envelope)
- make_transport() async detection (with/without loop, integration, http2)
- AsyncHttpTransport specifics (header parsing, capture_envelope, kill)
- Client async methods (close_async, flush_async, __aenter__/__aexit__)
- Client component helpers (_close_components, _flush_components)
- asyncio integration (patch_loop_close, _create_task_with_factory)
- ContextVar utilities (is_internal_task, mark_sentry_task_internal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use a sync test to test the no-running-loop path — there's genuinely
no running loop in a sync test, so no mock needed and no leaked
coroutines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After AsyncWorker.kill() cancels tasks, the event loop needs a tick
to actually process the cancellations. Without this, pytest reports
PytestUnraisableExceptionWarning for never-awaited coroutines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When kill() cancels the _target task while it's waiting on queue.get(),
the CancelledError propagates through the coroutine. Without catching
it, the coroutine gets garbage collected with an unhandled exception,
causing pytest's PytestUnraisableExceptionWarning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On Python 3.8, cancelled asyncio coroutines that were awaiting
Queue.get() raise GeneratorExit during garbage collection, triggering
PytestUnraisableExceptionWarning. This is a Python 3.8 asyncio
limitation, not a real bug. Suppress the warning for async worker tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- AsyncWorker.kill(): Reset queue to None instead of putting a stale
  _TERMINATOR (since we now cancel the task directly, the terminator
  was never consumed and would break restart)
- close() with async transport: Call _flush_components() to flush
  session flusher, log/metrics/span batchers even when sync flush
  is skipped
- Update test to verify fresh queue creation after kill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@BYK BYK force-pushed the feat/async-transport branch from 99e3031 to 86d6e36 Compare March 20, 2026 14:14
BYK and others added 2 commits March 23, 2026 17:01
Resolve conflicts in config.py and tox.ini: keep our anyio>=3,<5
change and the new jinja2 FastAPI dependency from master.
- AsyncWorker: Create fresh queue on start() instead of nullifying in
  kill(). This avoids the race where kill() nulls the queue before
  _on_task_complete can call task_done(), which would hang queue.join().
- AsyncHttpTransport._get_pool_options: Respect keep_alive option
  (was unconditionally adding keep-alive socket options).
- close() with async transport: Call _flush_components() before cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pytest-cov doesn't track coverage from code running inside asyncio
event loops. Add 70 synchronous tests that exercise async code paths
using mocks instead of actual event loops. This ensures coverage is
tracked in the main thread where the coverage tracer runs.

Also fix: pin anyio<4 for older httpx versions (0.16, 0.20) that
predate anyio 4.x compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bind the queue reference when the task is dispatched, not when the
done callback fires. This prevents kill()/start() from replacing
self._queue before old callbacks can call task_done(), which would
corrupt the new queue's unfinished_tasks counter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@sentrivana sentrivana left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not done reviewing everything but thought I'd already submit what I have to not block the PR for long. Still need to look at the tests in test_transport.py and some of the worker and transport logic.

- Remove all hasattr(transport, 'loop') checks in client.py — redundant
  since AsyncHttpTransport.__init__ always sets self.loop
- Remove irrelevant lore entries from AGENTS.md (Consola, Zod, remark-lint)
- Remove duplicate and implementation-detail tests per reviewer:
  - asyncio tests: keep only e2e tests (test_internal_tasks_not_wrapped,
    test_loop_close_patching, test_loop_close_flushes_async_transport)
  - client tests: remove _close_components/_flush_components detail tests
  - transport tests: remove all sync-wrapper and mock-based coverage tests
    that were not reviewable
- Remove section separator comments from test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When httpcore[asyncio] is not installed, AsyncHttpTransport is aliased
to HttpTransport. isinstance(transport, AsyncHttpTransport) would then
be True for ALL transports, causing close()/flush() to skip the sync
flush path. Guard with ASYNC_TRANSPORT_ENABLED flag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@BYK BYK force-pushed the feat/async-transport branch from 0f3a2b2 to 1ac4196 Compare March 26, 2026 12:36
test_loop_close_flushes_async_transport needs ASYNC_TRANSPORT_ENABLED
to be True. In gevent env (no httpcore[asyncio]), the _flush function
correctly skips async transport handling, so close_async is never
called. Skip the test in that case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Major cleanup that removes ~390 lines while preserving all functionality:

Architecture:
- Always define AsyncHttpTransport as a real class (not aliased to
  HttpTransport when deps missing). Raises RuntimeError in __init__
  instead. This eliminates the isinstance aliasing bug and removes
  most type: ignore annotations.
- Add _is_async_transport() helper to _Client, replacing 4 repeated
  isinstance checks.

Deduplication in transport.py:
- Extract _get_httpcore_pool_options() into HttpTransportCore — shared
  by AsyncHttpTransport and Http2Transport (SSL, certs, socket opts).
- Extract _resolve_proxy() into HttpTransportCore — shared proxy URL
  resolution logic.
- Extract _timeout_extensions property — shared timeout config dict.
- Extract _get_httpcore_header_value() module function — shared
  case-insensitive header lookup for httpcore responses.
- Enable HTTP/2 in AsyncHttpTransport when DSN scheme is HTTPS.

Simplification in client.py:
- Use _batchers tuple property in _flush_components/_close_components
  to avoid repeating the same None-check pattern for each batcher.

Test cleanup:
- Extract PROXY_TESTCASES and SOCKS_PROXY_TESTCASES constants shared
  between sync and async proxy tests (-300 lines).
- Add _make_async_transport_options() helper for make_transport tests.
- Remove section separator comments.
- Trim Worker ABC docstrings to one-liners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
With AsyncHttpTransport always defined as a real class, mypy properly
sees the class hierarchy and no longer flags _send_envelope and
_send_request as incompatible overrides.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The original Http2Transport always added KEEP_ALIVE_SOCKET_OPTIONS
unconditionally. The refactored _get_httpcore_pool_options incorrectly
wrapped this in an 'if keep_alive' guard. Remove the guard to match
the original behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants