Skip to content

fix: reuse Gemini client instead of building one per call#7

Merged
JamesMcMinn merged 2 commits into
mainfrom
fix/reuse-gemini-client
Jun 17, 2026
Merged

fix: reuse Gemini client instead of building one per call#7
JamesMcMinn merged 2 commits into
mainfrom
fix/reuse-gemini-client

Conversation

@JamesMcMinn

Copy link
Copy Markdown
Contributor

Problem

run_prompt / run_prompt_async constructed a fresh genai.Client on every call and never closed it. The client owns httpx connection pools, so under sustained load the abandoned pools accumulate and process memory climbs steadily over time. This was surfaced investigating a slow memory leak in the claims-analysis-api, where every Gemini-backed task (claim matching, claimant detection, checkworthy keyword filter, pastel) routes through these functions.

Fix

  • Cache the sync client per (project, location) via _get_client and reuse it. The sync client is thread-safe and the set of pairs is effectively one, so this is a bounded process-lifetime singleton.
  • run_prompt now calls the sync models.generate_content API directly — also dropping the per-call asyncio.run event loop.
  • run_prompt_async still builds a client per call (its async transport binds to the running event loop, which is often short-lived — e.g. driven under asyncio.run — so it can't be safely shared), but now closes it in a finally so the pool is released rather than leaked.
  • Extracted _build_request / _parse_response so the sync and async paths share request building and response handling with no behavioural drift.

Verification

  • uv run pytest → 102 passed (added test_run_prompt_reuses_cached_client and test_run_prompt_async_closes_client)
  • uv run ruff check / format → clean
  • uv run mypy src/genai_utils/gemini.py → clean

Follow-up

run_prompt_async still creates a client per call (correctness over the cross-event-loop hazard). If pastel's fan-out makes that churn show up in latency, a follow-up could reuse one client across the gathered calls within a single request.

🤖 Generated with Claude Code

Each run_prompt/run_prompt_async call constructed a fresh genai.Client
and never closed it. The client owns httpx connection pools, so under
sustained load the abandoned pools accumulated and process memory grew
steadily.

- Cache the sync client per (project, location) and reuse it; the sync
  client is thread-safe and the set of pairs is tiny, so this is a
  bounded process-lifetime singleton.
- run_prompt now calls the sync API directly, dropping the per-call
  asyncio.run event loop as well.
- run_prompt_async still builds a client per call (its async transport
  binds to the running loop, which is often short-lived), but now closes
  it in a finally so the pool is released.
- Extract _build_request / _parse_response so the sync and async paths
  share request building and response handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JamesMcMinn JamesMcMinn requested a review from dearden June 17, 2026 06:48

@dearden dearden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems fine. I am a little confused why it fixed it differently for sync and async (would finally not work for both?) but I don't object - all makes sense. Had one little question about TTL which may be unfounded.

Comment thread src/genai_utils/gemini.py
)
try:
response = await client.aio.models.generate_content(**request)
finally:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't object to how it's doing either, but how come it didn't do both async and sync the same way (with finally?)

Comment thread src/genai_utils/gemini.py Outdated


@lru_cache(maxsize=None)
def _get_client(project: str, location: str) -> genai.Client:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense, but I am wondering - if the claims analysis API is always running, will the clients not become stale without some kind of timeout. Shouldn't we set some kind of TTL?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It uses a httpx connection pool which should be fine as it should handle removing idle connections behind the scenes.

@JamesMcMinn JamesMcMinn merged commit b58f182 into main Jun 17, 2026
2 checks passed
@JamesMcMinn

Copy link
Copy Markdown
Contributor Author

I have just realised that this is literally the worst possible description for what this PR does now, but alas, the button has been pressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants