Skip to content

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

Merged
sentrivana merged 34 commits intomasterfrom
feat/async-transport
Mar 30, 2026
Merged

feat: Add experimental async transport (port of PR #4572)#5646
sentrivana merged 34 commits intomasterfrom
feat/async-transport

Conversation

@BYK
Copy link
Copy Markdown
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
Copy Markdown
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

  • Update validate-pr action to remove draft enforcement by stephanie-anderson in #5918
  • 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

Documentation 📚

  • Update CONTRIBUTING.md with contribution requirements and TOC by stephanie-anderson in #5896

Internal Changes 🔧

Ai

  • Remove unused GEN_AI_PIPELINE operation constant by ericapisani in #5886
  • Rename generate_text to text_completion by ericapisani in #5885

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

  • (asyncpg) Normalize query whitespace in integration by ericapisani in #5855
  • 🤖 Update test matrix with new releases (03/30) by github-actions in #5912
  • Merge PR validation workflows and add reason-specific labels by stephanie-anderson in #5898
  • Add workflow to close unvetted non-maintainer PRs by stephanie-anderson in #5895
  • 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
Copy Markdown
Contributor

github-actions bot commented Mar 12, 2026

Codecov Results 📊

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

All tests are passing successfully.

❌ Patch coverage is 21.78%. Project has 14679 uncovered lines.

Files with missing lines (7)
File Patch % Lines
utils.py 52.65% ⚠️ 437 Missing and 79 partials
transport.py 24.30% ⚠️ 408 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
Copy Markdown
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>
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>
Per sentrivana's review:

Source changes:
- Rename ASYNC_TRANSPORT_ENABLED -> ASYNC_TRANSPORT_AVAILABLE
- Rename _is_async_transport -> _has_async_transport on _Client
- Use warnings.warn instead of logger.warning for close()/flush()
  with async transport
- Restore keep_alive guard in _get_httpcore_pool_options (the
  original Http2Transport behavior was intentional, but the reviewer
  wants it guarded)
- Add httpcore[asyncio] to tox.jinja for linters/mypy/common
- Revert tox.ini to origin/master (auto-generated, needs regen)
- Revert AGENTS.md lore section

Test removals (~1050 lines):
- Remove ALL test_async_worker_* tests (implementation details)
- Remove ALL test_make_transport_* tests (implementation details)
- Remove all test_handle_response_*, test_update_headers,
  test_prepare_envelope_* tests (implementation details or duplicates)
- Remove test_close_async_awaits_kill_task (duplicate)
- Remove test_handle_request_error_basic_coverage (too much mocking)
- Remove test_async_transport_event_loop_closed_scenario (mocking-heavy)
- Remove test_async_transport_get_header_value (no added benefit)
- Remove misc implementation-detail async transport tests
- Apply make_client() simplification suggestions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tox.ini needs httpcore[asyncio] in common/linters/mypy deps until it
can be regenerated from the template. Also fix the test import that
still used the old ASYNC_TRANSPORT_ENABLED name.

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

@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.

BYK and others added 5 commits March 27, 2026 15:25
- Update test_close_with_async_transport_warns and
  test_flush_with_async_transport_warns to check warnings.warn
  instead of logger.warning
- Add missing @skip_under_gevent, @pytest.mark.asyncio, and
  @pytest.mark.skipif decorators on test_async_transport_concurrent_requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
looks like we were doing this before, so removing it would be a regression
@sentrivana
Copy link
Copy Markdown
Contributor

Made a couple changes. I'll take it for a spin in a local project next.

Comment on lines +1048 to +1056
if self._has_async_transport():
warnings.warn(
"close() used with AsyncHttpTransport. Use close_async() instead.",
stacklevel=2,
)
self._flush_components()
else:
self.flush(timeout=timeout, callback=callback)
self._close_components()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Calling the synchronous client.close() with an AsyncHttpTransport queues session flushes via call_soon_threadsafe but immediately kills the worker, causing silent data loss.
Severity: HIGH

Suggested Fix

The synchronous close() method should not attempt to flush async components if it cannot guarantee their completion. Either remove the call to self._flush_components() within the if self._has_async_transport() block to avoid the false promise of flushing, or modify the logic to block and wait for the async flush operations to complete before proceeding to kill the transport.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: sentry_sdk/client.py#L1048-L1056

Potential issue: When the synchronous `client.close()` method is called on a client
configured with an `AsyncHttpTransport`, it attempts to flush pending sessions by
calling `_flush_components()`. This queues session envelopes for transport using
`loop.call_soon_threadsafe()`, which returns immediately. The `close()` method then
proceeds to call `transport.kill()`, which cancels the async worker task responsible for
processing the queue. Due to this race condition, the newly queued session updates are
never processed and are silently lost before the worker is terminated.

Copy link
Copy Markdown

@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.

async def _flush_client_reports(self: "Self", force: bool = False) -> None:
client_report = self._fetch_pending_client_report(force=force, interval=60)
if client_report is not None:
self.capture_envelope(Envelope(items=[client_report]))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Async flush may lose client reports during shutdown

Medium Severity

AsyncHttpTransport._flush_client_reports calls self.capture_envelope(...), which routes through call_soon_threadsafe to schedule _capture_envelope on the event loop. This means the client report's queue.put() happens asynchronously on a future event loop iteration, after the flush lambda's task_done() fires. The flush's queue.join() can return before the client report is enqueued. During close_async(), the worker is then killed and the report is permanently lost. The sync BaseHttpTransport submits client reports directly to the queue (synchronously), so they are always waited on during flush.

Additional Locations (1)
Fix in Cursor Fix in Web

or self.span_batcher
or has_profiling_enabled(self.options)
or isinstance(self.transport, BaseHttpTransport)
or isinstance(self.transport, HttpTransportCore)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

uwsgi thread check incorrectly triggered for async transport

Low Severity

The guard condition was changed from isinstance(self.transport, BaseHttpTransport) to isinstance(self.transport, HttpTransportCore). Since AsyncHttpTransport inherits from HttpTransportCore, the uwsgi thread-support warning now fires for async transports too. AsyncHttpTransport uses AsyncWorker (asyncio tasks), not background threads, so the uwsgi thread check is irrelevant and could produce a spurious warning.

Fix in Cursor Fix in Web

@sentrivana
Copy link
Copy Markdown
Contributor

Added this from local testing: 4a2a6a3

Comment on lines +80 to +88
def _patched_close() -> None:
try:
loop.run_until_complete(_flush())
except Exception:
logger.debug(
"Could not flush Sentry events during loop close", exc_info=True
)
finally:
orig_close()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The patch_loop_close hook fails to flush events with asyncio.run() because the AsyncWorker task is cancelled before the flush is attempted, causing is_alive to return False.
Severity: CRITICAL

Suggested Fix

The shutdown logic should be modified to ensure the flush operation is initiated and awaited before the worker task is cancelled. The is_alive check in AsyncWorker.flush() prevents flushing when the worker task is already done, which is the case when asyncio.run() is used.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: sentry_sdk/integrations/asyncio.py#L80-L88

Potential issue: When using `asyncio.run()`, all running tasks are cancelled before the
event loop is closed. This causes the `AsyncWorker._task` to be marked as done.
Consequently, when the `_patched_close` hook later attempts to flush pending events by
calling `AsyncWorker.flush()`, the `is_alive` check fails because `_task.done()` is
`True`. This results in `AsyncWorker.flush()` returning `None` immediately, and any
queued Sentry events are lost instead of being sent.

Copy link
Copy Markdown

@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 3 potential issues.

Fix All in Cursor

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

or self.span_batcher
or has_profiling_enabled(self.options)
or isinstance(self.transport, BaseHttpTransport)
or isinstance(self.transport, HttpTransportCore)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

uWSGI thread check falsely triggers for async transport

Low Severity

The isinstance check changed from BaseHttpTransport to HttpTransportCore, which now also matches AsyncHttpTransport. Since AsyncHttpTransport uses AsyncWorker (asyncio tasks, not threads), triggering check_uwsgi_thread_support() for it is a false positive. The check should remain BaseHttpTransport to only match sync transports that actually spawn background threads via BackgroundWorker.

Fix in Cursor Fix in Web

return self

async def __aexit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None:
await self.close_async()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Async context manager silently skips cleanup for sync transport

Medium Severity

__aexit__ delegates to close_async(), which silently returns without any cleanup when the transport is not async. This means async with Client(...) with a sync transport (the default) leaks the transport, background worker, session flusher, batchers, and monitor — none are flushed or killed. Falling back to the synchronous close() would prevent the resource leak.

Additional Locations (1)
Fix in Cursor Fix in Web

if task._source_traceback: # type: ignore
del task._source_traceback[-1] # type: ignore

return task
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Extracted helper reuses consumed coroutine on factory fallback

Low Severity

_create_task_with_factory passes the same coro to both orig_task_factory and the Task() fallback. If the factory consumes/schedules the coroutine but returns None, the fallback would attempt to reuse an already-consumed coroutine, raising a RuntimeError. The old inline code created a fresh coroutine for each path.

Fix in Cursor Fix in Web

@sentrivana sentrivana merged commit 2604409 into master Mar 30, 2026
158 checks passed
@sentrivana sentrivana deleted the feat/async-transport branch March 30, 2026 11:13
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