Skip to content

Commit 658ef19

Browse files
committed
fix(chat): Heroku-safe Gemini retries, 429 JSON, and tests
- Use shorter default retries/sleeps on Heroku web dynos (DYNO=web.*) so requests finish before the ~30s router timeout; keep env overrides. - Log Gemini ClientError details (http_code, api status, message, details) for debugging 429s without exposing secrets. - Treat google.genai ClientError HTTP status via .code (not .status_code) and return 503 JSON for rate limits from /rest/v1/completion. - Add unit tests for retry helpers and completion route behavior. Made-with: Cursor
1 parent f77d131 commit 658ef19

4 files changed

Lines changed: 227 additions & 11 deletions

File tree

application/prompt_client/vertex_prompt_client.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def _is_genai_rate_limit_error(err: Exception) -> bool:
4242
if status_code == 429:
4343
return True
4444

45+
# google.genai.errors.ClientError / APIError use ``code`` (HTTP status).
4546
code = getattr(err, "code", None)
4647
if code == 429:
4748
return True
@@ -57,6 +58,50 @@ def _is_genai_rate_limit_error(err: Exception) -> bool:
5758
return False
5859

5960

61+
def _log_genai_client_error(context: str, exc: genai.errors.ClientError) -> None:
62+
"""Log full Gemini error payload (no secrets) for support and debugging."""
63+
logger.warning(
64+
"Gemini ClientError [%s]: http_code=%s api_status=%s message=%s details=%r",
65+
context,
66+
getattr(exc, "code", None),
67+
getattr(exc, "status", None),
68+
getattr(exc, "message", None),
69+
getattr(exc, "details", None),
70+
)
71+
72+
73+
def _is_heroku_web_dyno() -> bool:
74+
"""Heroku sets DYNO=web.* on HTTP dynos; the router enforces a ~30s request window."""
75+
dyno = os.environ.get("DYNO", "")
76+
return dyno.startswith("web.")
77+
78+
79+
def _effective_gemini_generate_retry_settings() -> tuple[int, int]:
80+
"""(max_retries, sleep_seconds) for generate_content; env vars always override."""
81+
if _is_heroku_web_dyno():
82+
return (
83+
int(os.environ.get("GEMINI_GENERATE_MAX_RETRIES", "1")),
84+
int(os.environ.get("GEMINI_GENERATE_RETRY_SLEEP_SECONDS", "6")),
85+
)
86+
return (
87+
int(os.environ.get("GEMINI_GENERATE_MAX_RETRIES", "3")),
88+
int(os.environ.get("GEMINI_GENERATE_RETRY_SLEEP_SECONDS", "60")),
89+
)
90+
91+
92+
def _effective_vertex_embed_retry_settings() -> tuple[int, int]:
93+
"""(max_retries, sleep_seconds) for embed_content; env vars always override."""
94+
if _is_heroku_web_dyno():
95+
return (
96+
int(os.environ.get("VERTEX_EMBED_MAX_RETRIES", "1")),
97+
int(os.environ.get("VERTEX_EMBED_RETRY_SLEEP_SECONDS", "6")),
98+
)
99+
return (
100+
int(os.environ.get("VERTEX_EMBED_MAX_RETRIES", "3")),
101+
int(os.environ.get("VERTEX_EMBED_RETRY_SLEEP_SECONDS", "60")),
102+
)
103+
104+
60105
class VertexPromptClient:
61106
context = (
62107
'You are "OpenCRE Chat" a chatbot for security information that exists in opencre.org. '
@@ -96,17 +141,17 @@ def _with_genai_rate_limit_retry(
96141
Bounded retries for `generate_content` (SDK may retry briefly; this adds
97142
longer backoff for sustained quota pressure).
98143
99-
Configure via ``GEMINI_GENERATE_MAX_RETRIES`` (default 3) and
100-
``GEMINI_GENERATE_RETRY_SLEEP_SECONDS`` (default 60).
144+
Configure via ``GEMINI_GENERATE_MAX_RETRIES`` and
145+
``GEMINI_GENERATE_RETRY_SLEEP_SECONDS``. On Heroku ``web.*`` dynos the
146+
defaults are reduced so the request can finish before the ~30s router
147+
timeout (long sleeps used to abort the Gunicorn worker mid-request).
101148
"""
102-
max_retries = int(os.environ.get("GEMINI_GENERATE_MAX_RETRIES", "3"))
103-
retry_sleep_seconds = int(
104-
os.environ.get("GEMINI_GENERATE_RETRY_SLEEP_SECONDS", "60")
105-
)
149+
max_retries, retry_sleep_seconds = _effective_gemini_generate_retry_settings()
106150
for attempt in range(max_retries + 1):
107151
try:
108152
return fn()
109153
except genai.errors.ClientError as e:
154+
_log_genai_client_error(context, e)
110155
if not _is_genai_rate_limit_error(e) or attempt >= max_retries:
111156
raise
112157
logger.info(
@@ -150,10 +195,7 @@ def _truncate_one(t: str) -> str:
150195
texts: List[str] = text if is_batch else [_truncate_one(text)] # type: ignore[arg-type]
151196
texts = [_truncate_one(t) for t in texts]
152197

153-
max_retries = int(os.environ.get("VERTEX_EMBED_MAX_RETRIES", "3"))
154-
retry_sleep_seconds = int(
155-
os.environ.get("VERTEX_EMBED_RETRY_SLEEP_SECONDS", "60")
156-
)
198+
max_retries, retry_sleep_seconds = _effective_vertex_embed_retry_settings()
157199

158200
for attempt in range(max_retries + 1):
159201
try:
@@ -173,6 +215,7 @@ def _truncate_one(t: str) -> str:
173215
return [emb.values for emb in result.embeddings]
174216
return result.embeddings[0].values
175217
except genai.errors.ClientError as e:
218+
_log_genai_client_error("embed_content", e)
176219
if not _is_genai_rate_limit_error(e) or attempt >= max_retries:
177220
raise
178221
logger.info(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Tests for /rest/v1/completion (chatbot) error handling."""
2+
3+
import json
4+
import os
5+
import unittest
6+
from unittest.mock import patch
7+
8+
from google.genai import errors as genai_errors
9+
10+
from application import create_app, sqla # type: ignore
11+
12+
13+
class TestChatCompletion(unittest.TestCase):
14+
def tearDown(self) -> None:
15+
sqla.session.remove()
16+
sqla.drop_all()
17+
self.app_context.pop()
18+
os.environ.pop("NO_LOGIN", None)
19+
20+
def setUp(self) -> None:
21+
self.app = create_app(mode="test")
22+
self.app_context = self.app.app_context()
23+
self.app_context.push()
24+
os.environ["INSECURE_REQUESTS"] = "True"
25+
sqla.create_all()
26+
27+
def test_completion_returns_503_json_on_gemini_429(self) -> None:
28+
os.environ["NO_LOGIN"] = "1"
29+
err = genai_errors.ClientError(
30+
429,
31+
{
32+
"error": {
33+
"code": 429,
34+
"message": "Resource exhausted",
35+
"status": "RESOURCE_EXHAUSTED",
36+
}
37+
},
38+
None,
39+
)
40+
with patch(
41+
"application.prompt_client.prompt_client.PromptHandler"
42+
) as mock_ph:
43+
mock_ph.return_value.generate_text.side_effect = err
44+
with self.app.test_client() as client:
45+
response = client.post(
46+
"/rest/v1/completion",
47+
json={"prompt": "test"},
48+
content_type="application/json",
49+
)
50+
self.assertEqual(503, response.status_code)
51+
data = json.loads(response.data)
52+
self.assertIn("error", data)
53+
self.assertIn("rate-limited", data["error"])
54+
55+
def test_completion_propagates_non_429_genai_error(self) -> None:
56+
os.environ["NO_LOGIN"] = "1"
57+
err = genai_errors.ClientError(
58+
400,
59+
{"error": {"code": 400, "message": "Bad request", "status": "INVALID_ARGUMENT"}},
60+
None,
61+
)
62+
with patch(
63+
"application.prompt_client.prompt_client.PromptHandler"
64+
) as mock_ph:
65+
mock_ph.return_value.generate_text.side_effect = err
66+
with self.app.test_client() as client:
67+
with self.assertRaises(genai_errors.ClientError) as ctx:
68+
client.post(
69+
"/rest/v1/completion",
70+
json={"prompt": "test"},
71+
content_type="application/json",
72+
)
73+
self.assertIs(ctx.exception, err)
74+
75+
76+
if __name__ == "__main__":
77+
unittest.main()
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Tests for Gemini retry helpers and rate-limit detection."""
2+
3+
import os
4+
import unittest
5+
6+
from google.genai import errors as genai_errors
7+
8+
from application.prompt_client.vertex_prompt_client import (
9+
_effective_gemini_generate_retry_settings,
10+
_effective_vertex_embed_retry_settings,
11+
_is_genai_rate_limit_error,
12+
_is_heroku_web_dyno,
13+
)
14+
15+
16+
class TestVertexPromptClientHelpers(unittest.TestCase):
17+
def tearDown(self) -> None:
18+
for key in (
19+
"DYNO",
20+
"GEMINI_GENERATE_MAX_RETRIES",
21+
"GEMINI_GENERATE_RETRY_SLEEP_SECONDS",
22+
"VERTEX_EMBED_MAX_RETRIES",
23+
"VERTEX_EMBED_RETRY_SLEEP_SECONDS",
24+
):
25+
os.environ.pop(key, None)
26+
27+
def test_is_heroku_web_dyno_true_for_web_prefix(self) -> None:
28+
os.environ["DYNO"] = "web.1"
29+
self.assertTrue(_is_heroku_web_dyno())
30+
31+
def test_is_heroku_web_dyno_false_for_worker(self) -> None:
32+
os.environ["DYNO"] = "worker.1"
33+
self.assertFalse(_is_heroku_web_dyno())
34+
35+
def test_is_heroku_web_dyno_false_when_unset(self) -> None:
36+
self.assertFalse(_is_heroku_web_dyno())
37+
38+
def test_effective_gemini_retry_defaults_local(self) -> None:
39+
self.assertEqual(_effective_gemini_generate_retry_settings(), (3, 60))
40+
41+
def test_effective_gemini_retry_defaults_heroku_web(self) -> None:
42+
os.environ["DYNO"] = "web.1"
43+
self.assertEqual(_effective_gemini_generate_retry_settings(), (1, 6))
44+
45+
def test_effective_gemini_retry_env_overrides_heroku(self) -> None:
46+
os.environ["DYNO"] = "web.1"
47+
os.environ["GEMINI_GENERATE_MAX_RETRIES"] = "2"
48+
os.environ["GEMINI_GENERATE_RETRY_SLEEP_SECONDS"] = "10"
49+
self.assertEqual(_effective_gemini_generate_retry_settings(), (2, 10))
50+
51+
def test_effective_embed_retry_defaults_local(self) -> None:
52+
self.assertEqual(_effective_vertex_embed_retry_settings(), (3, 60))
53+
54+
def test_effective_embed_retry_defaults_heroku_web(self) -> None:
55+
os.environ["DYNO"] = "web.1"
56+
self.assertEqual(_effective_vertex_embed_retry_settings(), (1, 6))
57+
58+
def test_is_genai_rate_limit_error_recognizes_clienterror_code_429(self) -> None:
59+
err = genai_errors.ClientError(
60+
429,
61+
{
62+
"error": {
63+
"code": 429,
64+
"message": "Resource exhausted",
65+
"status": "RESOURCE_EXHAUSTED",
66+
}
67+
},
68+
None,
69+
)
70+
self.assertTrue(_is_genai_rate_limit_error(err))
71+
72+
def test_is_genai_rate_limit_error_false_for_other_clienterror(self) -> None:
73+
err = genai_errors.ClientError(
74+
400,
75+
{"error": {"code": 400, "message": "Bad request", "status": "INVALID_ARGUMENT"}},
76+
None,
77+
)
78+
self.assertFalse(_is_genai_rate_limit_error(err))
79+
80+
81+
if __name__ == "__main__":
82+
unittest.main()

application/web/web_main.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -918,10 +918,24 @@ def chat_cre() -> Any:
918918

919919
database = db.Node_collection()
920920
# Lazy import to avoid loading heavy prompt/ML dependencies at web boot.
921+
from google.genai import errors as genai_errors
921922
from application.prompt_client import prompt_client
922923

923924
prompt = prompt_client.PromptHandler(database)
924-
response = prompt.generate_text(message.get("prompt"))
925+
try:
926+
response = prompt.generate_text(message.get("prompt"))
927+
except genai_errors.ClientError as e:
928+
# google.genai APIError uses ``code`` (HTTP status), not ``status_code``.
929+
if getattr(e, "code", None) == 429:
930+
return jsonify(
931+
{
932+
"error": (
933+
"The AI service is temporarily rate-limited. "
934+
"Please try again in a minute."
935+
)
936+
}
937+
), 503
938+
raise
925939
return jsonify(response)
926940

927941

0 commit comments

Comments
 (0)