Skip to content

Commit 135d34a

Browse files
committed
fix(models): stop CompletionsHTTPClient leaking its httpx client via atexit
CompletionsHTTPClient._client registered its per-instance httpx.AsyncClient with atexit.register(self._cleanup_client, client), leaving a permanent strong reference to the client and its connection pool in the process-global atexit registry. Nothing ever called atexit.unregister, so neither close(), aclose(), nor __del__ could release the client before interpreter exit. A long-running deployment that creates many CompletionsHTTPClient instances therefore grew the atexit registry and retained httpx clients without bound. Register the cleanup callback against weakref.proxy(client) so the registry no longer pins the client, mirroring the existing pattern in bigquery_agent_analytics_plugin, and unregister the per-instance callback in close()/aclose() once the client is explicitly closed. Tolerate a dead proxy in _cleanup_client, and retain the fire-and-forget aclose() task in a module-level set so it is not garbage collected while pending. Add regression tests covering the register/unregister pairing and asserting the client is releasable after close(). Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
1 parent 3127f36 commit 135d34a

2 files changed

Lines changed: 117 additions & 3 deletions

File tree

src/google/adk/models/apigee_llm.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import collections.abc
2121
import enum
2222
from functools import cached_property
23+
from functools import partial
2324
import json
2425
import logging
2526
import os
@@ -28,6 +29,7 @@
2829
from typing import Generator
2930
from typing import Optional
3031
from typing import TYPE_CHECKING
32+
import weakref
3133

3234
from google.adk import version as adk_version
3335
from google.genai import types
@@ -402,6 +404,11 @@ def _validate_model_string(model: str) -> bool:
402404
return False
403405

404406

407+
# Keeps a strong reference to fire-and-forget cleanup tasks so they are not
408+
# garbage collected before they finish (see CompletionsHTTPClient._cleanup_client).
409+
_CLEANUP_TASKS: set[asyncio.Task] = set()
410+
411+
405412
class CompletionsHTTPClient:
406413
"""A generic HTTP client for completions, compatible with OpenAI API."""
407414

@@ -427,17 +434,29 @@ def _client(self) -> httpx.AsyncClient:
427434
timeout=None,
428435
follow_redirects=True,
429436
)
430-
atexit.register(self._cleanup_client, client)
437+
# Register with a weakref.proxy so the atexit registry does not keep the
438+
# client (and its connection pool) alive for the whole process. Keep the
439+
# bound callback per instance so close()/aclose() can unregister just this
440+
# client's handler without affecting other CompletionsHTTPClient instances.
441+
self._atexit_callback = partial(self._cleanup_client, weakref.proxy(client))
442+
atexit.register(self._atexit_callback)
431443
return client
432444

433445
@staticmethod
434446
def _cleanup_client(client: httpx.AsyncClient) -> None:
435447
"""Cleans up the httpx client."""
436-
if client.is_closed:
448+
try:
449+
if client.is_closed:
450+
return
451+
except ReferenceError:
452+
# The client was already garbage collected via its weakref.proxy.
437453
return
438454
try:
439455
loop = asyncio.get_running_loop()
440-
loop.create_task(client.aclose())
456+
task = loop.create_task(client.aclose())
457+
# Retain a reference so the task is not garbage collected while pending.
458+
_CLEANUP_TASKS.add(task)
459+
task.add_done_callback(_CLEANUP_TASKS.discard)
441460
except RuntimeError:
442461
try:
443462
# This fails if asyncio.run is already called in main and is closing.
@@ -449,10 +468,12 @@ def close(self) -> None:
449468
if '_client' not in self.__dict__:
450469
return
451470
self._cleanup_client(self._client)
471+
atexit.unregister(self._atexit_callback)
452472

453473
async def aclose(self) -> None:
454474
if '_client' not in self.__dict__:
455475
return
476+
atexit.unregister(self._atexit_callback)
456477
if self._client.is_closed:
457478
return
458479
await self._client.aclose()

tests/unittests/models/test_completions_http_client.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import atexit
16+
import gc
1517
import json
1618
from unittest import mock
1719
from unittest.mock import AsyncMock
20+
import weakref
1821

1922
from google.adk.models.apigee_llm import ChatCompletionsResponseHandler
2023
from google.adk.models.apigee_llm import CompletionsHTTPClient
@@ -829,3 +832,93 @@ def test_process_chunk_with_refusal_streaming():
829832
final_response.content.parts[0].text
830833
== 'Hello\n[[REFUSAL]]: I refuse to answer'
831834
)
835+
836+
837+
def test_client_creation_registers_atexit_cleanup():
838+
local_client = CompletionsHTTPClient(base_url='https://localhost')
839+
with mock.patch(
840+
'google.adk.models.apigee_llm.atexit.register'
841+
) as mock_register:
842+
_ = local_client._client
843+
mock_register.assert_called_once_with(local_client._atexit_callback)
844+
845+
846+
def test_close_unregisters_atexit_cleanup():
847+
local_client = CompletionsHTTPClient(base_url='https://localhost')
848+
_ = local_client._client
849+
callback = local_client._atexit_callback
850+
with mock.patch(
851+
'google.adk.models.apigee_llm.atexit.unregister'
852+
) as mock_unregister:
853+
local_client.close()
854+
mock_unregister.assert_called_once_with(callback)
855+
856+
857+
@pytest.mark.asyncio
858+
async def test_aclose_unregisters_atexit_cleanup():
859+
local_client = CompletionsHTTPClient(base_url='https://localhost')
860+
_ = local_client._client
861+
callback = local_client._atexit_callback
862+
with mock.patch(
863+
'google.adk.models.apigee_llm.atexit.unregister'
864+
) as mock_unregister:
865+
await local_client.aclose()
866+
mock_unregister.assert_called_once_with(callback)
867+
assert local_client._client.is_closed
868+
869+
870+
def test_close_without_client_does_not_touch_atexit():
871+
local_client = CompletionsHTTPClient(base_url='https://localhost')
872+
with mock.patch(
873+
'google.adk.models.apigee_llm.atexit.unregister'
874+
) as mock_unregister:
875+
local_client.close()
876+
mock_unregister.assert_not_called()
877+
878+
879+
def test_atexit_registration_does_not_pin_client():
880+
# The atexit handler is registered against a weakref.proxy, so the registry
881+
# must not keep the underlying httpx client alive after it is closed and its
882+
# owner is dropped. Regression test for the leaked AsyncClient / connection
883+
# pool that grew unbounded in long-running deployments.
884+
local_client = CompletionsHTTPClient(base_url='https://localhost')
885+
httpx_client = local_client._client
886+
client_ref = weakref.ref(httpx_client)
887+
888+
local_client.close()
889+
del httpx_client
890+
del local_client
891+
gc.collect()
892+
893+
assert client_ref() is None
894+
895+
896+
def test_cleanup_client_tolerates_dead_weakref_proxy():
897+
local_client = CompletionsHTTPClient(base_url='https://localhost')
898+
httpx_client = local_client._client
899+
proxy = weakref.proxy(httpx_client)
900+
del httpx_client
901+
del local_client
902+
gc.collect()
903+
904+
# The proxy now points at a collected object; cleanup must not raise.
905+
CompletionsHTTPClient._cleanup_client(proxy)
906+
907+
908+
@pytest.mark.asyncio
909+
async def test_cleanup_client_retains_pending_task():
910+
from google.adk.models import apigee_llm
911+
912+
local_client = CompletionsHTTPClient(base_url='https://localhost')
913+
httpx_client = local_client._client
914+
915+
apigee_llm._CLEANUP_TASKS.clear()
916+
CompletionsHTTPClient._cleanup_client(httpx_client)
917+
918+
# While the aclose() task is pending it must be held in the module-level set
919+
# so it is not garbage collected before it completes.
920+
assert len(apigee_llm._CLEANUP_TASKS) == 1
921+
task = next(iter(apigee_llm._CLEANUP_TASKS))
922+
await task
923+
assert apigee_llm._CLEANUP_TASKS == set()
924+
assert httpx_client.is_closed

0 commit comments

Comments
 (0)