From 688d4d375051853669cbc1065183613f15e03d25 Mon Sep 17 00:00:00 2001 From: Alex Windels Date: Fri, 7 Aug 2026 13:22:44 +0200 Subject: [PATCH] Bound Gemini curation requests with a timeout and log progress Fixes #5. genai.Client() was constructed with no http_options, so the SDK's default timeout behavior applied -- its own retry loop (tenacity, on 408/429/5xx) logs nothing, and an unset timeout can mean an unbounded wait on a stalled connection. A real run appeared to hang indefinitely with zero output during curation as a result. Pass an explicit 60s http_options timeout to bound each attempt, and log immediately before/after the request so a multi-minute wait during retries is visible and distinguishable from an actual hang. Also documents this in the README's curation section. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 ++ src/candy/curation/gemini.py | 20 +++++++++++++++++++- tests/test_curation.py | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index da94e27..40fed84 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ To skip this entirely, use Gemini to curate automatically instead: 2. Get a free API key at [aistudio.google.com/app/api-keys](https://aistudio.google.com/app/api-keys) 3. Run with `--curation-backend gemini --curation-api-key YOUR_KEY`, or set it once via `$env:GOOGLE_API_KEY="YOUR_KEY"` (PowerShell) / `export GOOGLE_API_KEY=YOUR_KEY` (bash) and just pass `--curation-backend gemini` +**If this step appears to hang with nothing printing** (v3.0.2+): CANDy now logs before and after the Gemini request, and bounds each attempt to a 60-second timeout, so a multi-minute wait during retries is visible and finite rather than silent. On an older version, this step had no request timeout at all and could hang indefinitely on a stalled connection with zero output -- if you hit this, `Ctrl+C`, upgrade (`pip install --upgrade candy-cazyme`), and rerun. + ### Python API ```python diff --git a/src/candy/curation/gemini.py b/src/candy/curation/gemini.py index 2865e15..c26a633 100644 --- a/src/candy/curation/gemini.py +++ b/src/candy/curation/gemini.py @@ -1,8 +1,18 @@ from __future__ import annotations import ast +import logging import os +logger = logging.getLogger(__name__) + +# Bounds a single request attempt so a hung/slow connection can't stall the +# pipeline indefinitely and invisibly -- the underlying SDK retries up to 5 +# times by default (on 408/429/5xx) via tenacity, which does not log +# anything itself, so without this a stall here can look identical to a +# genuine hang even though it's just silently backing off and retrying. +_REQUEST_TIMEOUT_MS = 60_000 + _PROMPT_TEMPLATE = ( "I have the following list of protein domains, retrieved from InterPro's member " "databases: {domain_names}. These sequences {family_clause}. I want you to group " @@ -40,6 +50,7 @@ def __init__(self, api_key: str | None = None, model: str = "gemini-flash-latest def curate(self, domain_names: list[str], *, family: str | None = None) -> dict[str, list[str]]: try: from google import genai + from google.genai import types except ImportError as exc: raise ImportError( "Gemini curation requires the optional 'gemini' extra: " @@ -51,8 +62,15 @@ def curate(self, domain_names: list[str], *, family: str | None = None) -> dict[ ) prompt = _PROMPT_TEMPLATE.format(domain_names=domain_names, family_clause=family_clause) - client = genai.Client(api_key=self.api_key) + logger.info( + "Requesting domain-name curation from Gemini (model=%s, %d domain names). This can take " + "a while if the API is transiently retrying -- it isn't stuck if it takes a few minutes.", + self.model, + len(domain_names), + ) + client = genai.Client(api_key=self.api_key, http_options=types.HttpOptions(timeout=_REQUEST_TIMEOUT_MS)) response = client.models.generate_content(model=self.model, contents=prompt) + logger.info("Received curation response from Gemini.") cleaned = response.text.replace("python", "").replace("```", "") return ast.literal_eval(cleaned) diff --git a/tests/test_curation.py b/tests/test_curation.py index 533e866..b7c3018 100644 --- a/tests/test_curation.py +++ b/tests/test_curation.py @@ -1,3 +1,7 @@ +from unittest.mock import MagicMock, patch + +import pytest + from candy.curation import get_curation_backend from candy.curation.manual import ManualCurationBackend @@ -43,7 +47,34 @@ def test_get_curation_backend_returns_manual(): def test_get_curation_backend_unknown_raises(): - import pytest - with pytest.raises(ValueError): get_curation_backend("unknown-backend") + + +def test_gemini_curate_bounds_request_with_an_explicit_timeout_and_logs_progress(caplog): + # Regression test: a real run appeared to "hang" with zero output during + # curation. The SDK's own retry loop (tenacity, on 408/429/5xx) doesn't + # log anything, and no request timeout was set, so a stall here was + # indistinguishable from a genuine hang. Assert both fixes: an explicit + # timeout is passed to the client, and progress is logged before/after. + pytest.importorskip("google.genai") + from candy.curation.gemini import GeminiCurationBackend + + fake_response = MagicMock() + fake_response.text = "{'Catalytic domain': ['A', 'B']}" + fake_client = MagicMock() + fake_client.models.generate_content.return_value = fake_response + + with patch("google.genai.Client", return_value=fake_client) as mock_client_cls, caplog.at_level("INFO"): + backend = GeminiCurationBackend(api_key="fake-key") + result = backend.curate(["A", "B"], family="GH173") + + assert result == {"Catalytic domain": ["A", "B"]} + + _, kwargs = mock_client_cls.call_args + assert kwargs["api_key"] == "fake-key" + assert kwargs["http_options"].timeout == 60_000 + + messages = [record.message for record in caplog.records] + assert any("Requesting domain-name curation from Gemini" in m for m in messages) + assert any("Received curation response from Gemini" in m for m in messages)