Skip to content

Commit 7872421

Browse files
Merge pull request #1668 from gooddata/snapshot-master-3d2a457e-to-rel/dev
[bot] Merge master/3d2a457e into rel/dev
2 parents 26cb207 + 3d2a457 commit 7872421

2 files changed

Lines changed: 250 additions & 12 deletions

File tree

packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,88 @@
1414
"""
1515

1616
import json
17+
import logging
18+
import os
19+
import time
1720
from dataclasses import dataclass, field
18-
from typing import Any, Iterable
21+
from typing import Any, Callable, Iterable, TypeVar
1922

2023
import httpx
2124

2225
from gooddata_eval.core.models import ChatResult, DatasetItem
2326

27+
_log = logging.getLogger(__name__)
28+
2429
SSE_DATA_PREFIX = "data: "
2530

31+
_RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 502, 503, 504})
32+
_METADATA_SYNC_MARKER = "METADATA_SYNC_IN_PROGRESS"
33+
34+
35+
class ChatError(RuntimeError):
36+
"""Non-retryable error reported by the chat SSE stream."""
37+
38+
def __init__(self, message: str, *, status_code: int | None = None, detail: str | None = None) -> None:
39+
super().__init__(message)
40+
self.status_code = status_code
41+
self.detail = detail
42+
43+
44+
class TransientChatError(ChatError):
45+
"""Retryable transient error: gen-ai temporarily unavailable or still syncing metadata."""
46+
47+
48+
def _int_env(name: str, default: int) -> int:
49+
"""Read an int from the environment, falling back to ``default`` when unset or blank."""
50+
raw = os.getenv(name)
51+
return int(raw) if raw else default
52+
53+
54+
def _float_env(name: str, default: float) -> float:
55+
"""Read a float from the environment, falling back to ``default`` when unset or blank."""
56+
raw = os.getenv(name)
57+
return float(raw) if raw else default
58+
59+
60+
# Retry budget. Defaults give a ~2 min worst-case cap per send (5/10/20/40/60s);
61+
# overridable via env so CI can retune without cutting a new gooddata-eval release.
62+
_MAX_RETRIES = _int_env("GOODDATA_EVAL_CHAT_MAX_RETRIES", 5)
63+
_INITIAL_BACKOFF_S = _float_env("GOODDATA_EVAL_CHAT_INITIAL_BACKOFF_S", 5.0)
64+
_BACKOFF_FACTOR = _float_env("GOODDATA_EVAL_CHAT_BACKOFF_FACTOR", 2.0)
65+
_MAX_BACKOFF_S = _float_env("GOODDATA_EVAL_CHAT_MAX_BACKOFF_S", 60.0)
66+
67+
T = TypeVar("T")
68+
69+
70+
def _is_retryable_exc(exc: Exception) -> bool:
71+
if isinstance(exc, TransientChatError):
72+
return True
73+
if isinstance(exc, httpx.HTTPStatusError):
74+
return exc.response.status_code in _RETRYABLE_STATUS_CODES
75+
return False
76+
77+
78+
def _retry_transient(operation: Callable[[], T], *, is_retryable: Callable[[Exception], bool]) -> T:
79+
"""Run ``operation``; retry retryable failures with bounded exponential backoff."""
80+
delay = _INITIAL_BACKOFF_S
81+
for attempt in range(_MAX_RETRIES + 1): # 0..N => N retries + 1 initial attempt
82+
try:
83+
return operation()
84+
except Exception as exc: # noqa: PERF203 — retry loop: per-attempt try/except is intentional
85+
if attempt == _MAX_RETRIES or not is_retryable(exc):
86+
raise
87+
sleep_s = min(delay, _MAX_BACKOFF_S)
88+
_log.warning(
89+
"Transient gen-ai error (attempt %d/%d): %s; retrying in %.0fs",
90+
attempt + 1,
91+
_MAX_RETRIES + 1,
92+
exc,
93+
sleep_s,
94+
)
95+
time.sleep(sleep_s)
96+
delay *= _BACKOFF_FACTOR
97+
raise AssertionError("unreachable") # loop either returns or raises
98+
2699

27100
@dataclass
28101
class _SseAccumulator:
@@ -114,12 +187,23 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
114187
if not line or line.startswith("event: ") or not line.startswith(SSE_DATA_PREFIX):
115188
continue
116189
data_str = line[len(SSE_DATA_PREFIX) :]
190+
if _METADATA_SYNC_MARKER in data_str:
191+
raise TransientChatError(
192+
f"SSE transient error: {_METADATA_SYNC_MARKER}",
193+
status_code=None,
194+
detail=None,
195+
)
117196
try:
118197
event_data = json.loads(data_str)
119198
except json.JSONDecodeError:
120199
continue
121200
if "statusCode" in event_data:
122-
raise RuntimeError(f"SSE error {event_data.get('statusCode')}: {event_data.get('detail')}")
201+
code = event_data.get("statusCode")
202+
detail = event_data.get("detail")
203+
message = f"SSE error {code}: {detail}"
204+
if code in _RETRYABLE_STATUS_CODES:
205+
raise TransientChatError(message, status_code=code, detail=detail)
206+
raise ChatError(message, status_code=code, detail=detail)
123207
item = event_data.get("item")
124208
if not item:
125209
continue
@@ -149,12 +233,17 @@ def __init__(self, host: str, token: str, workspace_id: str, *, timeout: float =
149233
self._client = httpx.Client(timeout=timeout)
150234

151235
def create_conversation(self) -> str:
152-
resp = self._client.post(self._base, headers={**self._auth, "Content-Type": "application/json"})
153-
resp.raise_for_status()
154-
body = resp.json()
155-
if "conversationId" not in body:
156-
raise ValueError(f"GoodData /chat/conversations response missing 'conversationId': {body}")
157-
return body["conversationId"]
236+
def _do() -> str:
237+
resp = self._client.post(self._base, headers={**self._auth, "Content-Type": "application/json"})
238+
resp.raise_for_status()
239+
body = resp.json()
240+
if "conversationId" not in body:
241+
raise ValueError(f"GoodData /chat/conversations response missing 'conversationId': {body}")
242+
return body["conversationId"]
243+
244+
# NOTE: retrying create is not idempotent — a created-then-503 can leak an
245+
# orphaned (ephemeral) conversation. Acceptable for eval; do not reuse blindly.
246+
return _retry_transient(_do, is_retryable=_is_retryable_exc)
158247

159248
def delete_conversation(self, conversation_id: str) -> None:
160249
try:
@@ -166,9 +255,13 @@ def send_message(self, conversation_id: str, question: str) -> ChatResult:
166255
url = f"{self._base}/{conversation_id}/messages"
167256
headers = {**self._auth, "Accept": "text/event-stream", "Content-Type": "application/json"}
168257
body = {"item": {"role": "user", "content": {"type": "text", "text": question}}}
169-
with self._client.stream("POST", url, json=body, headers=headers) as resp:
170-
resp.raise_for_status()
171-
return parse_sse_lines(resp.iter_lines())
258+
259+
def _do() -> ChatResult:
260+
with self._client.stream("POST", url, json=body, headers=headers) as resp:
261+
resp.raise_for_status()
262+
return parse_sse_lines(resp.iter_lines())
263+
264+
return _retry_transient(_do, is_retryable=_is_retryable_exc)
172265

173266
def ask(self, item: DatasetItem) -> ChatResult:
174267
"""Run one single-turn conversation: create, send, parse, clean up."""

packages/gooddata-eval/tests/test_sse_client.py

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# (C) 2026 GoodData Corporation
22
import json
33

4+
import httpx
45
import pytest
5-
from gooddata_eval.core.chat.sse_client import parse_sse_lines
6+
from gooddata_eval.core.chat import sse_client as sse_mod
7+
from gooddata_eval.core.chat.sse_client import ChatClient, ChatError, TransientChatError, parse_sse_lines
68

79

810
def test_parse_sse_lines_collects_text_and_visualization(fixtures_dir):
@@ -77,3 +79,146 @@ def test_parse_sse_lines_prefers_multipart_viz_over_adhoc_fallback():
7779
]
7880
result = parse_sse_lines(lines)
7981
assert result.created_visualizations.objects[0].id == "real"
82+
83+
84+
@pytest.mark.parametrize("code", [429, 502, 503, 504])
85+
def test_parse_sse_lines_transient_status_codes(code):
86+
with pytest.raises(TransientChatError) as ei:
87+
parse_sse_lines([f'data: {{"statusCode": {code}, "detail": null}}'])
88+
assert ei.value.status_code == code
89+
90+
91+
def test_parse_sse_lines_metadata_sync_is_transient():
92+
with pytest.raises(TransientChatError):
93+
parse_sse_lines(['data: {"reasonCode": "METADATA_SYNC_IN_PROGRESS"}'])
94+
95+
96+
def test_parse_sse_lines_metadata_sync_marker_in_malformed_json_is_transient():
97+
# marker present but the data payload is not valid JSON -> still transient, not swallowed
98+
with pytest.raises(TransientChatError):
99+
parse_sse_lines(["data: {bad json METADATA_SYNC_IN_PROGRESS"])
100+
101+
102+
def test_parse_sse_lines_non_retryable_status_is_chat_error_not_transient():
103+
with pytest.raises(ChatError) as ei:
104+
parse_sse_lines(['data: {"statusCode": 400, "detail": "bad"}'])
105+
assert not isinstance(ei.value, TransientChatError)
106+
assert ei.value.status_code == 400
107+
108+
109+
def _client_with_handler(handler):
110+
client = ChatClient(host="https://example.invalid", token="t", workspace_id="w")
111+
client._client = httpx.Client(transport=httpx.MockTransport(handler))
112+
return client
113+
114+
115+
_TRANSIENT_SSE = b'data: {"statusCode": 503, "detail": null}\n'
116+
_NONRETRY_SSE = b'data: {"statusCode": 400, "detail": "bad"}\n'
117+
_OK_SSE = b'data: {"item": {"role": "assistant", "content": {"type": "text", "text": "ok"}}}\n'
118+
119+
120+
def test_send_message_retries_transient_then_succeeds(monkeypatch):
121+
sleeps = []
122+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
123+
calls = {"n": 0}
124+
125+
def handler(request):
126+
calls["n"] += 1
127+
return httpx.Response(200, content=_TRANSIENT_SSE if calls["n"] < 3 else _OK_SSE)
128+
129+
client = _client_with_handler(handler)
130+
result = client.send_message("conv", "q")
131+
assert result.text_response == "ok"
132+
assert calls["n"] == 3
133+
assert sleeps == [5, 10]
134+
135+
136+
def test_send_message_backoff_schedule_then_raises(monkeypatch):
137+
sleeps = []
138+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
139+
calls = {"n": 0}
140+
141+
def handler(request):
142+
calls["n"] += 1
143+
return httpx.Response(200, content=_TRANSIENT_SSE)
144+
145+
client = _client_with_handler(handler)
146+
with pytest.raises(TransientChatError):
147+
client.send_message("conv", "q")
148+
assert calls["n"] == 6 # 1 initial + 5 retries
149+
assert sleeps == [5, 10, 20, 40, 60]
150+
151+
152+
def test_send_message_does_not_retry_non_transient(monkeypatch):
153+
sleeps = []
154+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
155+
calls = {"n": 0}
156+
157+
def handler(request):
158+
calls["n"] += 1
159+
return httpx.Response(200, content=_NONRETRY_SSE)
160+
161+
client = _client_with_handler(handler)
162+
with pytest.raises(ChatError) as ei:
163+
client.send_message("conv", "q")
164+
assert not isinstance(ei.value, TransientChatError)
165+
assert calls["n"] == 1
166+
assert sleeps == []
167+
168+
169+
def test_create_conversation_retries_then_succeeds(monkeypatch):
170+
sleeps = []
171+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
172+
calls = {"n": 0}
173+
174+
def handler(request):
175+
calls["n"] += 1
176+
if calls["n"] < 3:
177+
return httpx.Response(503)
178+
return httpx.Response(200, json={"conversationId": "abc"})
179+
180+
client = _client_with_handler(handler)
181+
assert client.create_conversation() == "abc"
182+
assert calls["n"] == 3
183+
assert sleeps == [5, 10]
184+
185+
186+
def test_create_conversation_does_not_retry_4xx(monkeypatch):
187+
sleeps = []
188+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
189+
calls = {"n": 0}
190+
191+
def handler(request):
192+
calls["n"] += 1
193+
return httpx.Response(400)
194+
195+
client = _client_with_handler(handler)
196+
with pytest.raises(httpx.HTTPStatusError):
197+
client.create_conversation()
198+
assert calls["n"] == 1
199+
assert sleeps == []
200+
201+
202+
def test_int_env_uses_default_when_unset(monkeypatch):
203+
monkeypatch.delenv("GD_TEST_INT", raising=False)
204+
assert sse_mod._int_env("GD_TEST_INT", 5) == 5
205+
206+
207+
def test_int_env_uses_default_when_blank(monkeypatch):
208+
monkeypatch.setenv("GD_TEST_INT", "")
209+
assert sse_mod._int_env("GD_TEST_INT", 5) == 5
210+
211+
212+
def test_int_env_reads_override(monkeypatch):
213+
monkeypatch.setenv("GD_TEST_INT", "2")
214+
assert sse_mod._int_env("GD_TEST_INT", 5) == 2
215+
216+
217+
def test_float_env_uses_default_when_unset(monkeypatch):
218+
monkeypatch.delenv("GD_TEST_FLOAT", raising=False)
219+
assert sse_mod._float_env("GD_TEST_FLOAT", 5.0) == 5.0
220+
221+
222+
def test_float_env_reads_override(monkeypatch):
223+
monkeypatch.setenv("GD_TEST_FLOAT", "1.5")
224+
assert sse_mod._float_env("GD_TEST_FLOAT", 5.0) == 1.5

0 commit comments

Comments
 (0)