Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4f8a00c
feat: Add experimental async transport (port of PR #4572)
BYK Mar 12, 2026
82c0094
fix: Suppress mypy await type error in AsyncHttpTransport._request
BYK Mar 12, 2026
4b77519
fix: Move httpcore[asyncio] from global test deps to specific envs
BYK Mar 12, 2026
c46fb6f
fix: Cancel _target task in AsyncWorker.kill() and improve sync close()
BYK Mar 12, 2026
5ea3aac
fix: Skip async tests under gevent
BYK Mar 12, 2026
ff85a58
fix: Remove from __future__ import annotations for Python 3.6 compat
BYK Mar 16, 2026
156f32b
test: Add comprehensive coverage tests for async transport
BYK Mar 16, 2026
cb932d2
fix: Make test_async_worker_start_no_running_loop sync
BYK Mar 16, 2026
d19271e
fix: Add asyncio.sleep(0) after worker.kill() to clean up coroutines
BYK Mar 16, 2026
299947d
fix: Handle CancelledError in AsyncWorker._target
BYK Mar 16, 2026
71007ec
fix: Suppress PytestUnraisableExceptionWarning for async worker tests
BYK Mar 16, 2026
183e83b
Merge origin/master into feat/async-transport
BYK Mar 20, 2026
8883b78
test: Add sync wrapper tests for async code paths (coverage)
BYK Mar 20, 2026
86d6e36
fix: Address Bugbot feedback — stale terminator and flush components
BYK Mar 20, 2026
e74f4a7
Merge origin/master into feat/async-transport
BYK Mar 23, 2026
91072bb
fix: Address bot feedback from merge
BYK Mar 23, 2026
d64517f
test: Add pure-sync mock-based tests for async code coverage
BYK Mar 23, 2026
94b6c73
fix: Capture queue ref at dispatch time in _on_task_complete
BYK Mar 23, 2026
38f97c2
refactor: Address reviewer feedback
BYK Mar 26, 2026
1ac4196
fix: Guard isinstance AsyncHttpTransport with ASYNC_TRANSPORT_ENABLED
BYK Mar 26, 2026
b990610
fix: Skip loop close test when async transport deps missing
BYK Mar 26, 2026
b392bc4
refactor: Simplify and deduplicate async transport code
BYK Mar 26, 2026
36ad606
fix: Remove unused type:ignore on async method overrides
BYK Mar 26, 2026
025714e
fix: Always add keep-alive socket options in httpcore transports
BYK Mar 26, 2026
ff3e9a0
refactor: Address reviewer feedback — rename, simplify, remove tests
BYK Mar 27, 2026
9b0a712
fix: Re-add httpcore[asyncio] to tox.ini and fix renamed import
BYK Mar 27, 2026
f59c38c
fix: Fix test assertions for warnings.warn and missing decorators
BYK Mar 27, 2026
94666e1
remove extra section from agents.md
sentrivana Mar 30, 2026
a2a9588
Merge branch 'master' into feat/async-transport
sentrivana Mar 30, 2026
a8823a9
regen tox
sentrivana Mar 30, 2026
ed040a1
remove couple of tests
sentrivana Mar 30, 2026
ad2ceae
turn keep alive on by default in httpcore based transports
sentrivana Mar 30, 2026
4a2a6a3
only enable http2 on async transport if h2 is there
sentrivana Mar 30, 2026
c88848e
Merge branch 'master' into feat/async-transport
sentrivana Mar 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions scripts/populate_tox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
"pytest-asyncio",
"python-multipart",
"requests",
"anyio<4",
"anyio>=3,<5",
"jinja2",
],
# There's an incompatibility between FastAPI's TestClient, which is
Expand All @@ -133,6 +133,7 @@
# FastAPI versions we use older httpx which still supports the
# deprecated argument.
"<0.110.1": ["httpx<0.28.0"],
"<0.80": ["anyio<4"],
"py3.6": ["aiocontextvars"],
},
},
Expand Down Expand Up @@ -171,7 +172,8 @@
"httpx": {
"package": "httpx",
"deps": {
"*": ["anyio<4.0.0"],
"*": ["anyio>=3,<5"],
"<0.24": ["anyio<4"],
">=0.16,<0.17": ["pytest-httpx==0.10.0"],
">=0.17,<0.19": ["pytest-httpx==0.12.0"],
">=0.19,<0.21": ["pytest-httpx==0.14.0"],
Expand Down
6 changes: 3 additions & 3 deletions scripts/populate_tox/releases.jsonl

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions scripts/populate_tox/tox.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,17 @@

linters: -r requirements-linting.txt
linters: werkzeug<2.3.0
linters: httpcore[asyncio]

mypy: -r requirements-linting.txt
mypy: werkzeug<2.3.0
mypy: httpcore[asyncio]
ruff: -r requirements-linting.txt

# === Common ===
py3.8-common: hypothesis
common: pytest-asyncio

Check warning on line 90 in scripts/populate_tox/tox.jinja

View check run for this annotation

@sentry/warden / warden: code-review

httpcore[asyncio] dependency breaks Python 3.6/3.7 test environments

The `common: httpcore[asyncio]` dependency is added without Python version restrictions, but httpcore 1.x requires Python 3.8+. The common test environment runs on py3.6 and py3.7 (line 21 of template). This will cause pip installation failures in those environments. The dependency should be conditioned on Python 3.8+ similar to how `py3.8-common: hypothesis` is version-restricted.
common: httpcore[asyncio]
# See https://github.com/pytest-dev/pytest/issues/9621
# and https://github.com/pytest-dev/pytest-forked/issues/67
# for justification of the upper bound on pytest
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"configure_scope",
"continue_trace",
"flush",
"flush_async",
"get_baggage",
"get_client",
"get_global_scope",
Expand Down
9 changes: 9 additions & 0 deletions sentry_sdk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def overload(x: "T") -> "T":
"configure_scope",
"continue_trace",
"flush",
"flush_async",
"get_baggage",
"get_client",
"get_global_scope",
Expand Down Expand Up @@ -351,6 +352,14 @@ def flush(
return get_client().flush(timeout=timeout, callback=callback)


@clientmethod
async def flush_async(
timeout: "Optional[float]" = None,
callback: "Optional[Callable[[int, float], None]]" = None,
) -> None:
return await get_client().flush_async(timeout=timeout, callback=callback)


@scopemethod
def start_span(
**kwargs: "Any",
Expand Down
127 changes: 108 additions & 19 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@
from sentry_sdk.serializer import serialize
from sentry_sdk.tracing import trace
from sentry_sdk.tracing_utils import has_span_streaming_enabled
from sentry_sdk.transport import BaseHttpTransport, make_transport
from sentry_sdk.transport import (
HttpTransportCore,
make_transport,
AsyncHttpTransport,
)
from sentry_sdk.consts import (
SPANDATA,
DEFAULT_MAX_VALUE_LENGTH,
Expand Down Expand Up @@ -251,6 +255,12 @@
def flush(self, *args: "Any", **kwargs: "Any") -> None:
return None

async def close_async(self, *args: "Any", **kwargs: "Any") -> None:
return None

async def flush_async(self, *args: "Any", **kwargs: "Any") -> None:
return None

def __enter__(self) -> "BaseClient":
return self

Expand Down Expand Up @@ -472,7 +482,7 @@
or self.metrics_batcher
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

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

):
# If we have anything on that could spawn a background thread, we
# need to check if it's safe to use them.
Expand Down Expand Up @@ -999,6 +1009,32 @@

return self.integrations.get(integration_name)

def _has_async_transport(self) -> bool:
"""Check if the current transport is async."""
return isinstance(self.transport, AsyncHttpTransport)

@property
def _batchers(self) -> "tuple[Any, ...]":
return tuple(
b
for b in (self.log_batcher, self.metrics_batcher, self.span_batcher)
if b is not None
)

def _close_components(self) -> None:
"""Kill all client components in the correct order."""
self.session_flusher.kill()
for b in self._batchers:
b.kill()
if self.monitor:
self.monitor.kill()

def _flush_components(self) -> None:
"""Flush all client components."""
self.session_flusher.flush()
for b in self._batchers:
b.flush()

def close(
self,
timeout: "Optional[float]" = None,
Expand All @@ -1009,19 +1045,40 @@
semantics as :py:meth:`Client.flush`.
"""
if self.transport is not None:
self.flush(timeout=timeout, callback=callback)
self.session_flusher.kill()
if self.log_batcher is not None:
self.log_batcher.kill()
if self.metrics_batcher is not None:
self.metrics_batcher.kill()
if self.span_batcher is not None:
self.span_batcher.kill()
if self.monitor:
self.monitor.kill()
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()
Comment on lines +1048 to +1056
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.

self.transport.kill()

Check warning on line 1057 in sentry_sdk/client.py

View check run for this annotation

@sentry/warden / warden: code-review

[US2-WBK] Test will fail because isinstance check fails on Mock with spec (additional location)

The test creates `mock_transport = Mock(spec=AsyncHttpTransport)` and assigns it to `mock_client.transport`. However, in `_flush()` (asyncio.py:71), `isinstance(client.transport, AsyncHttpTransport)` will return `False` because a `Mock` object with a `spec` is not an actual instance of the class. This causes early return, so `close_async` is never called and the assertions at lines 680-681 will fail.
self.transport = None

async def close_async(
self,
timeout: "Optional[float]" = None,
callback: "Optional[Callable[[int, float], None]]" = None,
) -> None:
"""
Asynchronously close the client and shut down the transport. Arguments have the same
semantics as :py:meth:`Client.flush_async`.
"""
if self.transport is not None:
if not self._has_async_transport():
logger.debug(
"close_async() used with non-async transport, aborting. Please use close() instead."
)
return
await self.flush_async(timeout=timeout, callback=callback)
self._close_components()
kill_task = self.transport.kill() # type: ignore
if kill_task is not None:
await kill_task
self.transport = None

def flush(
self,
timeout: "Optional[float]" = None,
Expand All @@ -1035,23 +1092,55 @@
:param callback: Is invoked with the number of pending events and the configured timeout.
"""
if self.transport is not None:
if self._has_async_transport():
warnings.warn(
"flush() used with AsyncHttpTransport. Use flush_async() instead.",
stacklevel=2,
)
return
if timeout is None:
timeout = self.options["shutdown_timeout"]
self.session_flusher.flush()
if self.log_batcher is not None:
self.log_batcher.flush()
if self.metrics_batcher is not None:
self.metrics_batcher.flush()
if self.span_batcher is not None:
self.span_batcher.flush()
self._flush_components()

self.transport.flush(timeout=timeout, callback=callback)

async def flush_async(
self,
timeout: "Optional[float]" = None,
callback: "Optional[Callable[[int, float], None]]" = None,
) -> None:
"""
Asynchronously wait for the current events to be sent.

:param timeout: Wait for at most `timeout` seconds. If no `timeout` is provided, the `shutdown_timeout` option value is used.

:param callback: Is invoked with the number of pending events and the configured timeout.
"""
if self.transport is not None:
if not self._has_async_transport():
logger.debug(
"flush_async() used with non-async transport, aborting. Please use flush() instead."
)
return
if timeout is None:
timeout = self.options["shutdown_timeout"]
self._flush_components()
flush_task = self.transport.flush(timeout=timeout, callback=callback) # type: ignore
if flush_task is not None:
await flush_task

def __enter__(self) -> "_Client":
return self

def __exit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None:
self.close()

async def __aenter__(self) -> "_Client":
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



from typing import TYPE_CHECKING

Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class CompressionAlgo(Enum):
"transport_compression_algo": Optional[CompressionAlgo],
"transport_num_pools": Optional[int],
"transport_http2": Optional[bool],
"transport_async": Optional[bool],
"enable_logs": Optional[bool],
"before_send_log": Optional[Callable[[Log, Hint], Optional[Log]]],
"enable_metrics": Optional[bool],
Expand Down
Loading
Loading