From e5c0ff3294967f0e18b6dd186ed57875acd86de8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:02:54 -0700 Subject: [PATCH] fix(http): free short-lived event loops whose handler finalizer close finished as the loop stopped --- litellm/llms/custom_httpx/http_handler.py | 11 ++++++++++- .../unit/llms/custom_httpx/test_http_handler.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 0b64885df45..e19a101b817 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -1119,6 +1119,15 @@ class AsyncHTTPHandler: # Mirrors LiteLLMAiohttpTransport._background_close_tasks. _finalizer_close_tasks: ClassVar[set["asyncio.Task[None]"]] = set() # mutable-ok: strong refs for pending closes + @classmethod + async def _close_client_at_finalization(cls, client: httpx.AsyncClient) -> None: + try: + await client.aclose() + finally: + task: Final = asyncio.current_task() + if task is not None: + cls._finalizer_close_tasks.discard(task) + @classmethod def _on_finalizer_close_done(cls, task: "asyncio.Task[None]") -> None: cls._finalizer_close_tasks.discard(task) @@ -1186,8 +1195,8 @@ class AsyncHTTPHandler: # here is the cross-loop path the transport refuses. self._dispose_wrapped_aiohttp_session() return - task: Final = loop.create_task(self._client.aclose()) cls: Final = type(self) + task: Final = loop.create_task(cls._close_client_at_finalization(self._client)) cls._finalizer_close_tasks.add(task) task.add_done_callback(cls._on_finalizer_close_done) except Exception: diff --git a/tests/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index 1607447f7bf..5d02acea327 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -1388,6 +1388,22 @@ async def test_finalizer_with_running_loop_schedules_close_and_holds_task_ref(): assert not (AsyncHTTPHandler._finalizer_close_tasks & scheduled) + +async def _drop_unused_handler() -> None: + AsyncHTTPHandler(timeout=61.0) + gc.collect() + + +def test_finalizer_close_does_not_keep_a_loop_closed_right_after_it_alive(): + loop: Final = asyncio.new_event_loop() + loop.run_until_complete(_drop_unused_handler()) + loop.close() + closed_loop: Final = weakref.ref(loop) + del loop + gc.collect() + + assert closed_loop() is None + @pytest.mark.asyncio async def test_sync_close_helper_respects_session_ownership(): """The loop-less fallback closes only sessions the transport owns; a