From ba7d776955b8dbf067d8f782eb6e2bd6373e7c87 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 21 Jul 2026 11:14:38 -0400 Subject: [PATCH 1/5] fix(flags): close _async_client on sync __exit__ and shutdown() (SDK-85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both RemoteFeatureFlagsProvider and LocalFeatureFlagsProvider had sync __exit__ and shutdown() that closed only _sync_client, leaving _async_client's connection pool + background transport to leak until gc, which surfaced as ResourceWarning noise (and, on long-lived processes, real fd exhaustion). New helper `close_async_client_from_sync` in flags/utils.py handles both possible call sites: - No running event loop on the current thread (typical sync __exit__ / shutdown call site) → bridge to sync via asgiref async_to_sync and block until close completes. - A loop is already running (e.g. `shutdown()` invoked from an async function) → asgiref would raise RuntimeError here, so we schedule a background aclose() task and log a warning pointing callers at __aexit__ / awaiting aclose directly. Also folded RemoteFeatureFlagsProvider.__exit__ into a call to shutdown() to keep the two paths in sync, and made __aexit__ close _sync_client too so the async path is symmetric. New tests: - test_sync_shutdown_closes_both_clients - test_sync_context_manager_exit_closes_both_clients (one each in test_local_feature_flags.py and test_remote_feature_flags.py) - TestCloseAsyncClientFromSync covering both loop-running and no-loop code paths on the helper itself. Full flags suite: 103 pass. The 4 new provider tests fail on master. --- mixpanel/flags/local_feature_flags.py | 12 +++++-- mixpanel/flags/remote_feature_flags.py | 11 +++++- mixpanel/flags/test_local_feature_flags.py | 28 +++++++++++++++ mixpanel/flags/test_remote_feature_flags.py | 28 +++++++++++++++ mixpanel/flags/test_utils.py | 40 ++++++++++++++++++++- mixpanel/flags/utils.py | 33 +++++++++++++++++ 6 files changed, 148 insertions(+), 4 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 8465e79..b340be3 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -24,6 +24,7 @@ from .utils import ( EXPOSURE_EVENT, REQUEST_HEADERS, + close_async_client_from_sync, generate_traceparent, normalized_hash, prepare_common_query_params, @@ -544,8 +545,15 @@ async def __aenter__(self): return self def shutdown(self): + # SDK-85: close both clients from sync context. Historically only + # _sync_client.close() ran here, leaving _async_client's connection + # pool + background transport to leak (httpx emits a + # ResourceWarning at gc time). The helper bridges to sync via + # asgiref when no loop is running, or schedules a background + # aclose() task when called from an already-running loop. self.stop_polling_for_definitions() self._sync_client.close() + close_async_client_from_sync(self._async_client) def __enter__(self): return self @@ -554,8 +562,8 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): logger.info("Exiting the LocalFeatureFlagsProvider and cleaning up resources") await self.astop_polling_for_definitions() await self._async_client.aclose() + self._sync_client.close() def __exit__(self, exc_type, exc_val, exc_tb): logger.info("Exiting the LocalFeatureFlagsProvider and cleaning up resources") - self.stop_polling_for_definitions() - self._sync_client.close() + self.shutdown() diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 90a2ee6..066b0f8 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -21,6 +21,7 @@ from .utils import ( EXPOSURE_EVENT, REQUEST_HEADERS, + close_async_client_from_sync, generate_traceparent, prepare_common_query_params, ) @@ -417,7 +418,14 @@ def _lookup_flag_in_response( return fallback_value.as_fallback(FallbackReason.flag_not_found()), True def shutdown(self): + # SDK-85: close both clients from sync context. Historically only + # _sync_client.close() ran here, leaving _async_client's connection + # pool + background transport to leak (httpx emits a + # ResourceWarning at gc time). The helper bridges to sync via + # asgiref when no loop is running, or schedules a background + # aclose() task when called from an already-running loop. self._sync_client.close() + close_async_client_from_sync(self._async_client) def __enter__(self): return self @@ -427,8 +435,9 @@ async def __aenter__(self): def __exit__(self, exc_type, exc_val, exc_tb): logger.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources") - self._sync_client.close() + self.shutdown() async def __aexit__(self, exc_type, exc_val, exc_tb): logger.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources") await self._async_client.aclose() + self._sync_client.close() diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 9839b1b..0f0d0c9 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -951,3 +951,31 @@ def test_local_flags_fallback_to_token_without_credentials(): assert provider._request_params["lib_version"] == "1.0.0" provider.shutdown() + + +# SDK-85: sync __exit__ and shutdown() historically only closed +# _sync_client, leaking _async_client's connection pool + background +# transport. The two tests below fail on the pre-fix code. + + +def test_sync_shutdown_closes_both_clients(): + config = LocalFlagsConfig(enable_polling=False) + provider = LocalFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) + + assert not provider._sync_client.is_closed + assert not provider._async_client.is_closed + + provider.shutdown() + + assert provider._sync_client.is_closed + assert provider._async_client.is_closed + + +def test_sync_context_manager_exit_closes_both_clients(): + config = LocalFlagsConfig(enable_polling=False) + with LocalFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) as provider: + assert not provider._sync_client.is_closed + assert not provider._async_client.is_closed + + assert provider._sync_client.is_closed + assert provider._async_client.is_closed diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index 2ebc510..e9f3a5f 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -531,3 +531,31 @@ def test_remote_flags_fallback_to_token_without_credentials(): assert provider._request_params_base["lib_version"] == "1.0.0" provider.shutdown() + + +# SDK-85: sync __exit__ and shutdown() historically only closed +# _sync_client, leaking _async_client's connection pool + background +# transport. The two tests below fail on the pre-fix code. + + +def test_sync_shutdown_closes_both_clients(): + config = RemoteFlagsConfig() + provider = RemoteFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) + + assert not provider._sync_client.is_closed + assert not provider._async_client.is_closed + + provider.shutdown() + + assert provider._sync_client.is_closed + assert provider._async_client.is_closed + + +def test_sync_context_manager_exit_closes_both_clients(): + config = RemoteFlagsConfig() + with RemoteFeatureFlagsProvider("test-token", config, "1.0.0", Mock()) as provider: + assert not provider._sync_client.is_closed + assert not provider._async_client.is_closed + + assert provider._sync_client.is_closed + assert provider._async_client.is_closed diff --git a/mixpanel/flags/test_utils.py b/mixpanel/flags/test_utils.py index 6e02717..9480a25 100644 --- a/mixpanel/flags/test_utils.py +++ b/mixpanel/flags/test_utils.py @@ -1,10 +1,16 @@ from __future__ import annotations +import asyncio import re +import httpx import pytest -from .utils import generate_traceparent, normalized_hash +from .utils import ( + close_async_client_from_sync, + generate_traceparent, + normalized_hash, +) class TestUtils: @@ -31,3 +37,35 @@ def test_normalized_hash_for_known_inputs(self, key, salt, expected_hash): assert result == expected_hash, ( f"Expected hash of {expected_hash} for '{key}' with salt '{salt}', got {result}" ) + + +class TestCloseAsyncClientFromSync: + # SDK-85: shutdown() and __exit__ need to close the AsyncClient + # from sync context without breaking callers that happen to be + # inside a running event loop. + + def test_closes_client_when_no_loop_running(self): + client = httpx.AsyncClient() + assert not client.is_closed + + close_async_client_from_sync(client) + + assert client.is_closed + + @pytest.mark.asyncio + async def test_schedules_close_and_warns_when_loop_running(self, caplog): + # Inside an async test we already have a running loop — the + # helper must not raise (as async_to_sync would) and must + # schedule the aclose task for later. + client = httpx.AsyncClient() + assert not client.is_closed + + with caplog.at_level("WARNING", logger="mixpanel.flags.utils"): + close_async_client_from_sync(client) + + # Give the event loop a chance to execute the scheduled task. + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert client.is_closed + assert any("scheduled aclose" in rec.message for rec in caplog.records) diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index b847f1f..0cadfb8 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -1,9 +1,42 @@ from __future__ import annotations +import asyncio +import logging import uuid +import httpx +from asgiref.sync import async_to_sync + +logger = logging.getLogger(__name__) + EXPOSURE_EVENT = "$experiment_started" + +def close_async_client_from_sync(client: httpx.AsyncClient) -> None: + """SDK-85: close an ``httpx.AsyncClient`` from sync code. + + If no event loop is running on the current thread, bridges to sync + via ``asgiref.async_to_sync`` and blocks until close completes. + + If a loop is already running on this thread (e.g. ``shutdown()`` was + called from inside an async function), schedules a background + ``aclose`` task without blocking — ``async_to_sync`` would raise + ``RuntimeError`` in that scenario. Callers running under an event + loop should prefer ``__aexit__`` / awaiting ``aclose`` directly. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + async_to_sync(client.aclose)() + return + + loop.create_task(client.aclose()) + logger.warning( + "close_async_client_from_sync scheduled aclose() on a running " + "event loop and did not wait for it. Prefer awaiting aclose() " + "or using __aexit__ from async code." + ) + REQUEST_HEADERS: dict[str, str] = { "X-Scheme": "https", "X-Forwarded-Proto": "https", From 37e23abf17a2c179be7696648b1fd52a7a4ede59 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 21 Jul 2026 11:16:50 -0400 Subject: [PATCH 2/5] lint: satisfy ruff TC002/RUF006 in close_async_client_from_sync - Move `httpx` under TYPE_CHECKING (only used as a type hint). - Retain a hard reference to the fire-and-forget aclose() task in a module-level set with a done_callback that removes it once complete. Without the reference, the task can be gc'd before it runs. --- mixpanel/flags/utils.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 0cadfb8..9273cc2 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -3,12 +3,20 @@ import asyncio import logging import uuid +from typing import TYPE_CHECKING -import httpx from asgiref.sync import async_to_sync +if TYPE_CHECKING: + import httpx + logger = logging.getLogger(__name__) +# Retains a hard reference to fire-and-forget aclose() tasks scheduled +# from close_async_client_from_sync when a loop is already running, so +# they can't be gc'd before completing. Removed via done_callback. +_pending_aclose_tasks: set[asyncio.Task] = set() + EXPOSURE_EVENT = "$experiment_started" @@ -30,7 +38,9 @@ def close_async_client_from_sync(client: httpx.AsyncClient) -> None: async_to_sync(client.aclose)() return - loop.create_task(client.aclose()) + task = loop.create_task(client.aclose()) + _pending_aclose_tasks.add(task) + task.add_done_callback(_pending_aclose_tasks.discard) logger.warning( "close_async_client_from_sync scheduled aclose() on a running " "event loop and did not wait for it. Prefer awaiting aclose() " From 913d3db044358e51187f848bc0dbb6d53232c568 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 21 Jul 2026 11:19:18 -0400 Subject: [PATCH 3/5] style: ruff format mixpanel/flags/utils.py --- mixpanel/flags/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 9273cc2..9b66ec3 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -47,6 +47,7 @@ def close_async_client_from_sync(client: httpx.AsyncClient) -> None: "or using __aexit__ from async code." ) + REQUEST_HEADERS: dict[str, str] = { "X-Scheme": "https", "X-Forwarded-Proto": "https", From f45d9619603afa4237d5712ec93fad5ac8314014 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 21 Jul 2026 11:40:18 -0400 Subject: [PATCH 4/5] fix(flags): log aclose() task failures on the running-loop path (SDK-85) Greptile P2 on #184: the done_callback was only discarding the task from _pending_aclose_tasks and never retrieving its exception. If aclose() raised on the executor thread, the caller saw shutdown() return successfully and the failure only surfaced as an anonymous "Task exception was never retrieved" line at gc time. Split the done_callback into _on_aclose_task_done which discards the task, ignores cancellation, and logs any exception at ERROR with type + message. Added test_logs_error_when_scheduled_aclose_raises that patches aclose to raise and asserts the error log is emitted; fails on the previous callback wiring. --- mixpanel/flags/test_utils.py | 27 +++++++++++++++++++++++++++ mixpanel/flags/utils.py | 14 +++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/mixpanel/flags/test_utils.py b/mixpanel/flags/test_utils.py index 9480a25..9b7b5bb 100644 --- a/mixpanel/flags/test_utils.py +++ b/mixpanel/flags/test_utils.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +import logging import re +from unittest.mock import Mock import httpx import pytest @@ -69,3 +71,28 @@ async def test_schedules_close_and_warns_when_loop_running(self, caplog): assert client.is_closed assert any("scheduled aclose" in rec.message for rec in caplog.records) + + @pytest.mark.asyncio + async def test_logs_error_when_scheduled_aclose_raises(self, caplog): + # If the scheduled aclose() task raises, the done_callback must + # retrieve and log the exception. Otherwise the caller sees a + # successful shutdown() return and the failure only surfaces as + # an anonymous "Task exception was never retrieved" at gc time. + client = Mock(spec=httpx.AsyncClient) + + async def _boom(): + raise RuntimeError("aclose blew up") + + client.aclose.side_effect = _boom + + with caplog.at_level(logging.ERROR, logger="mixpanel.flags.utils"): + close_async_client_from_sync(client) + # Let the task run + done_callback fire. + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert any( + "Async HTTP client close failed" in rec.message + and "aclose blew up" in rec.message + for rec in caplog.records + ), f"expected error log, got {[r.message for r in caplog.records]}" diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 9b66ec3..2b5c5b1 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -40,7 +40,7 @@ def close_async_client_from_sync(client: httpx.AsyncClient) -> None: task = loop.create_task(client.aclose()) _pending_aclose_tasks.add(task) - task.add_done_callback(_pending_aclose_tasks.discard) + task.add_done_callback(_on_aclose_task_done) logger.warning( "close_async_client_from_sync scheduled aclose() on a running " "event loop and did not wait for it. Prefer awaiting aclose() " @@ -48,6 +48,18 @@ def close_async_client_from_sync(client: httpx.AsyncClient) -> None: ) +def _on_aclose_task_done(task: asyncio.Task) -> None: + _pending_aclose_tasks.discard(task) + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + # Without this, the caller sees a successful shutdown() return + # and the failure only surfaces as an anonymous "Task exception + # was never retrieved" line at gc time. + logger.error("Async HTTP client close failed: %s: %s", type(exc).__name__, exc) + + REQUEST_HEADERS: dict[str, str] = { "X-Scheme": "https", "X-Forwarded-Proto": "https", From 4caf5164aa64e907242e65246424beba840f6da5 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 23 Jul 2026 13:22:28 -0400 Subject: [PATCH 5/5] fix(flags): raise on close_async_client_from_sync from running loop (SDK-85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior running-loop branch scheduled a fire-and-forget aclose() on the current event loop. That has three problems the reviewers flagged: - Greptile P1: loop teardown can cancel the task before aclose finishes, leaving the same resource leak this change was meant to prevent. - Copilot: LocalFeatureFlagsProvider.shutdown() from an async context would schedule aclose() on _async_client while _async_polling_task was still using it (shutdown() only stops the sync polling thread). - Ketan (Option A): shutdown() returning success while cleanup is still pending is a misleading contract; better to force async callers onto __aexit__. Rewrites close_async_client_from_sync to raise RuntimeError when a loop is already running, pointing callers at __aexit__ / awaiting aclose() directly. Removes _pending_aclose_tasks, _on_aclose_task_done, the module logger, and the fire-and-forget scheduling — they only existed to make the (unsafe) running-loop path work. test_local_flags_async_with_service_account_credentials was calling provider.shutdown() from async context; the old fire-and-forget behavior masked this, the new contract surfaces it. Switched it to await provider.__aexit__(None, None, None). Replaced test_schedules_close_and_warns_when_loop_running and test_logs_error_when_scheduled_aclose_raises with test_raises_when_called_from_running_loop. 103 flag tests pass; ruff check + format clean. Co-Authored-By: Claude Opus 4.7 --- mixpanel/flags/local_feature_flags.py | 8 +-- mixpanel/flags/remote_feature_flags.py | 8 +-- mixpanel/flags/test_local_feature_flags.py | 3 +- mixpanel/flags/test_utils.py | 57 +++++----------------- mixpanel/flags/utils.py | 48 +++++------------- 5 files changed, 29 insertions(+), 95 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index b340be3..53bfb9d 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -545,12 +545,8 @@ async def __aenter__(self): return self def shutdown(self): - # SDK-85: close both clients from sync context. Historically only - # _sync_client.close() ran here, leaving _async_client's connection - # pool + background transport to leak (httpx emits a - # ResourceWarning at gc time). The helper bridges to sync via - # asgiref when no loop is running, or schedules a background - # aclose() task when called from an already-running loop. + # SDK-85: close both clients. close_async_client_from_sync raises + # if a loop is already running — async callers should use __aexit__. self.stop_polling_for_definitions() self._sync_client.close() close_async_client_from_sync(self._async_client) diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 066b0f8..d839978 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -418,12 +418,8 @@ def _lookup_flag_in_response( return fallback_value.as_fallback(FallbackReason.flag_not_found()), True def shutdown(self): - # SDK-85: close both clients from sync context. Historically only - # _sync_client.close() ran here, leaving _async_client's connection - # pool + background transport to leak (httpx emits a - # ResourceWarning at gc time). The helper bridges to sync via - # asgiref when no loop is running, or schedules a background - # aclose() task when called from an already-running loop. + # SDK-85: close both clients. close_async_client_from_sync raises + # if a loop is already running — async callers should use __aexit__. self._sync_client.close() close_async_client_from_sync(self._async_client) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 0f0d0c9..21302b3 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -918,8 +918,7 @@ async def test_local_flags_async_with_service_account_credentials(): assert provider._async_client.auth is not None assert isinstance(provider._async_client.auth, httpx.BasicAuth) - await provider._async_client.aclose() - provider.shutdown() + await provider.__aexit__(None, None, None) def test_local_flags_fallback_to_token_without_credentials(): diff --git a/mixpanel/flags/test_utils.py b/mixpanel/flags/test_utils.py index 9b7b5bb..efdf977 100644 --- a/mixpanel/flags/test_utils.py +++ b/mixpanel/flags/test_utils.py @@ -1,9 +1,6 @@ from __future__ import annotations -import asyncio -import logging import re -from unittest.mock import Mock import httpx import pytest @@ -43,8 +40,8 @@ def test_normalized_hash_for_known_inputs(self, key, salt, expected_hash): class TestCloseAsyncClientFromSync: # SDK-85: shutdown() and __exit__ need to close the AsyncClient - # from sync context without breaking callers that happen to be - # inside a running event loop. + # from sync context. Callers already inside a running event loop + # must use __aexit__ — see the raises test below. def test_closes_client_when_no_loop_running(self): client = httpx.AsyncClient() @@ -55,44 +52,14 @@ def test_closes_client_when_no_loop_running(self): assert client.is_closed @pytest.mark.asyncio - async def test_schedules_close_and_warns_when_loop_running(self, caplog): - # Inside an async test we already have a running loop — the - # helper must not raise (as async_to_sync would) and must - # schedule the aclose task for later. + async def test_raises_when_called_from_running_loop(self): + # Fire-and-forget scheduling on the running loop can be cancelled + # by loop teardown before aclose finishes, so the helper refuses + # the call and points async callers at __aexit__. client = httpx.AsyncClient() - assert not client.is_closed - - with caplog.at_level("WARNING", logger="mixpanel.flags.utils"): - close_async_client_from_sync(client) - - # Give the event loop a chance to execute the scheduled task. - await asyncio.sleep(0) - await asyncio.sleep(0) - - assert client.is_closed - assert any("scheduled aclose" in rec.message for rec in caplog.records) - - @pytest.mark.asyncio - async def test_logs_error_when_scheduled_aclose_raises(self, caplog): - # If the scheduled aclose() task raises, the done_callback must - # retrieve and log the exception. Otherwise the caller sees a - # successful shutdown() return and the failure only surfaces as - # an anonymous "Task exception was never retrieved" at gc time. - client = Mock(spec=httpx.AsyncClient) - - async def _boom(): - raise RuntimeError("aclose blew up") - - client.aclose.side_effect = _boom - - with caplog.at_level(logging.ERROR, logger="mixpanel.flags.utils"): - close_async_client_from_sync(client) - # Let the task run + done_callback fire. - await asyncio.sleep(0) - await asyncio.sleep(0) - - assert any( - "Async HTTP client close failed" in rec.message - and "aclose blew up" in rec.message - for rec in caplog.records - ), f"expected error log, got {[r.message for r in caplog.records]}" + try: + with pytest.raises(RuntimeError, match="running event loop"): + close_async_client_from_sync(client) + assert not client.is_closed + finally: + await client.aclose() diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 2b5c5b1..5a6ef7b 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import logging import uuid from typing import TYPE_CHECKING @@ -10,56 +9,33 @@ if TYPE_CHECKING: import httpx -logger = logging.getLogger(__name__) - -# Retains a hard reference to fire-and-forget aclose() tasks scheduled -# from close_async_client_from_sync when a loop is already running, so -# they can't be gc'd before completing. Removed via done_callback. -_pending_aclose_tasks: set[asyncio.Task] = set() - EXPOSURE_EVENT = "$experiment_started" def close_async_client_from_sync(client: httpx.AsyncClient) -> None: """SDK-85: close an ``httpx.AsyncClient`` from sync code. - If no event loop is running on the current thread, bridges to sync - via ``asgiref.async_to_sync`` and blocks until close completes. - - If a loop is already running on this thread (e.g. ``shutdown()`` was - called from inside an async function), schedules a background - ``aclose`` task without blocking — ``async_to_sync`` would raise - ``RuntimeError`` in that scenario. Callers running under an event - loop should prefer ``__aexit__`` / awaiting ``aclose`` directly. + Bridges to sync via ``asgiref.async_to_sync`` and blocks until the + close completes. If a loop is already running on the current thread + (e.g. ``shutdown()`` was called from inside an ``async def``), raises + ``RuntimeError`` — scheduling a background ``aclose`` on the running + loop is unsafe because loop teardown can cancel the task before it + finishes, defeating the fix. Async callers should use ``__aexit__`` + or await ``aclose()`` directly. """ try: - loop = asyncio.get_running_loop() + asyncio.get_running_loop() except RuntimeError: async_to_sync(client.aclose)() return - task = loop.create_task(client.aclose()) - _pending_aclose_tasks.add(task) - task.add_done_callback(_on_aclose_task_done) - logger.warning( - "close_async_client_from_sync scheduled aclose() on a running " - "event loop and did not wait for it. Prefer awaiting aclose() " - "or using __aexit__ from async code." + raise RuntimeError( + "close_async_client_from_sync() cannot be called from a running " + "event loop. Use 'async with provider:' or await the provider's " + "__aexit__ from async code." ) -def _on_aclose_task_done(task: asyncio.Task) -> None: - _pending_aclose_tasks.discard(task) - if task.cancelled(): - return - exc = task.exception() - if exc is not None: - # Without this, the caller sees a successful shutdown() return - # and the failure only surfaces as an anonymous "Task exception - # was never retrieved" line at gc time. - logger.error("Async HTTP client close failed: %s: %s", type(exc).__name__, exc) - - REQUEST_HEADERS: dict[str, str] = { "X-Scheme": "https", "X-Forwarded-Proto": "https",