fix(models): cache the Gemini client per event loop so reused agents survive loop teardown - #7187
Open
AtulJoshi1206 wants to merge 2 commits into
Open
AtulJoshi1206 wants to merge 2 commits into
AtulJoshi1206 wants to merge 2 commits into
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
…survive loop teardown Gemini.api_client and Gemini._live_api_client were functools.cached_property, so one google.genai.Client lived for the whole process. That client binds its async HTTP connection pool to the event loop that first drives it. Vertex AI Agent Engine serves each request on a new thread with a new asyncio.run() loop while reusing the same agent, so from the second request on the cached client was driven on a foreign loop and failed with "RuntimeError: Event loop is closed" (httpx transport; genai >= 2.11 already keys aiohttp sessions per loop). Cache the client per running event loop instead: a module-level helper keys clients by asyncio.get_running_loop(), builds outside the lock, and prunes entries whose loop is closed so the cache stays bounded by live loops. Both properties become plain properties backed by PrivateAttr dicts, with the construction moved into _build_api_client/_build_live_api_client. ApigeeLlm overrides the builder instead of api_client and inherits the fix. A caller-supplied client= is still returned as-is. Existing tests patched the cached_property as an instance attribute, which a property refuses; they now inject through the public client field. Fixes google#5538
AtulJoshi1206
force-pushed
the
fix/gemini-client-per-event-loop
branch
from
September 18, 2026 14:48
812c426 to
633d41b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
mainand confirmed the loop-keyed approach was sound)Problem:
Gemini.api_clientandGemini._live_api_clientarefunctools.cached_property, so onegoogle.genai.Clientis built perGeminiinstance and kept for the life of the process. Agoogle.genai.Clientbinds its async HTTP connection pool to the event loop that first drives it. Deployments such as Vertex AI Agent Engine serve every request on a fresh OS thread with a freshasyncio.run()loop while reusing the same agent (and therefore the sameGeminiinstance), so from the second request on the cached client is driven on a loop other than the one it is bound to and fails with:Users have been shipping an
UncachedGeminisubclass workaround for five months (see the issue thread).ApigeeLlmoverridesapi_clientwith the samecached_propertyand has the same defect.Reproduced on current
mainwith a local fake Gemini endpoint and one thread + oneasyncio.run()per request (three requests through oneGeminiinstance):google-genai≥ 2.11 keys its aiohttp sessions per loop)aiohttpis not installed; it is only in themcp/allextras)The intermittent pattern (a request fails, the next one succeeds once the dead pooled connection is evicted) matches the "after 3-5 requests" in the report.
Solution:
Cache the client per event loop instead of per instance.
google_llm.py: a module-level_client_for_running_loop(clients, build)keys clients byasyncio.get_running_loop()(Nonewhen no loop is running), builds outside the lock (construction may resolve credentials), and prunes entries whose loopis_closed()so the cache stays bounded by the number of live loops. Athreading.Lockguards the dict because loops on different OS threads read and prune it concurrently.Gemini.api_client/Gemini._live_api_clientbecome plain@propertys backed by two pydanticPrivateAttrdicts. Construction moves into_build_api_client()/_build_live_api_client(). A caller-suppliedclient=is still returned as-is; its lifecycle stays with the caller.ApigeeLlmnow overrides_build_api_client()instead ofapi_client, so it inherits the per-loop caching for free (13-line diff).Geminiclass docstring example no longer tells users to override withcached_property; it explains why that is unsafe across loops.Why this shape and not the alternatives:
@property(the workaround in the thread) fixes the crash but opens a new TCP+TLS connection on every access, losing connection pooling within a request.(loop, client)slot (the shape of fix: prevent RuntimeError: Event loop is closed for Gemini.api_client (#5538) #5543) thrashes when several request loops are alive at once on Agent Engine, rebuilding a client on nearly every access. A dict keyed by loop reuses each loop's client for the loop's whole lifetime.Behaviour unchanged: a
Geminiaccessed from one loop still gets exactly one client, so existing pooling and_api_backenddetection are untouched.Testing Plan
Unit Tests:
New tests (
tests/unittests/models/test_google_llm.py,test_apigee_llm.py), all through the public interface:asyncio.run()loopclient=shared across loops as-isApigeeLlmclient rebuilt per loop (constructor called twice)Test migration: the 44 existing sites that did
mock.patch.object(gemini_llm, "api_client" | "_live_api_client")relied oncached_propertybeing settable. They now patch the publicclientfield, which both properties honour, so the tests exercise the supported injection path and no longer depend on the caching mechanism. This is what broke CI on #5543.Manual End-to-End (E2E) Tests:
Script mimicking Agent Engine (one
Geminiinstance, each request on a new thread with its ownasyncio.run(), against a local fake endpoint returning a fixedgenerateContentresponse), run with the httpx transport forced:One thing reviewers will see in that log after the fix: when a stale client is garbage-collected,
google.genai'sBaseApiClient.__del__schedulesaclose()on whatever loop is currently running, and closing connections that belong to the finished loop logsTask exception was never retrieved ... Event loop is closed. That is SDK teardown noise, not a request failure, and it is identical to what the documentedUncachedGeminiworkaround produces today. Fixing it belongs ingoogle-genai(its destructor would need to skip connections from a foreign loop).Checklist
Additional context
Public API impact:
Gemini.api_clientstays a read-only attribute returning agoogle.genai.Client. The only observable change is for code that assigned togemini.api_client(possible withcached_property, not with a property); the supported path for that is theclient=field, which is what the migrated tests use.🤖 Generated with Claude Code