feat: Add experimental async transport (port of PR #4572)#5646
feat: Add experimental async transport (port of PR #4572)#5646sentrivana merged 34 commits intomasterfrom
Conversation
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. New Features ✨Langchain
Other
Bug Fixes 🐛Ci
Openai
Other
Documentation 📚
Internal Changes 🔧Ai
Langchain
Openai
Other
Other
🤖 This preview updates automatically when you update the PR. |
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)
Generated by Codecov Action |
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>
8c808bf to
4f8a00c
Compare
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>
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>
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
- 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
|
Made a couple changes. I'll take it for a spin in a local project next. |
| 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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
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])) |
There was a problem hiding this comment.
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)
| or self.span_batcher | ||
| or has_profiling_enabled(self.options) | ||
| or isinstance(self.transport, BaseHttpTransport) | ||
| or isinstance(self.transport, HttpTransportCore) |
There was a problem hiding this comment.
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.
|
Added this from local testing: 4a2a6a3 |
| 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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
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) |
There was a problem hiding this comment.
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.
| return self | ||
|
|
||
| async def __aexit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None: | ||
| await self.close_async() |
There was a problem hiding this comment.
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)
| if task._source_traceback: # type: ignore | ||
| del task._source_traceback[-1] # type: ignore | ||
|
|
||
| return task |
There was a problem hiding this comment.
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.


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
masterbranch.Key changes
transport.py: Refactor
BaseHttpTransportintoHttpTransportCore(shared base) +
BaseHttpTransport(sync) +AsyncHttpTransport(async, conditional on
httpcore[asyncio]). Extract shared helpers:_handle_request_error,_handle_response,_update_headers,_prepare_envelope. Updatemake_transport()to detect thetransport_asyncexperiment.worker.py: Add
WorkerABC base class andAsyncWorkerimplementation using
asyncio.Queue/asyncio.Task.client.py: Add
close_async()/flush_async()with async-vs-synctransport detection. Extract
_close_components()/_flush_components().api.py: Expose
flush_async()as a public API.integrations/asyncio.py: Patch
loop.closeto flush pending eventsbefore 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,<5for httpx and FastAPI.Notes
tox.iniwas manually edited (the generation script requires afree-threaded Python interpreter). A full regeneration should be done
before merge.
Refs: GH-4568