Skip to content

Commit f77ae37

Browse files
feat(eval): retry transient gen-ai SSE errors in chat client
Classify SSE error events as ChatError (non-retryable, still a RuntimeError) vs TransientChatError (status 429/502/503/504 or METADATA_SYNC_IN_PROGRESS, detected before JSON parse). Wrap ChatClient.send_message and create_conversation in a bounded exponential-backoff retry (5 retries, 5/10/20/40/60s, ~2min cap, stdlib only) so a staging metadata-sync queue peak no longer fails the E2E LLM tests. No new dependency. JIRA: GDAI-1929 risk: nonprod
1 parent 653f5bc commit f77ae37

2 files changed

Lines changed: 210 additions & 12 deletions

File tree

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

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

1616
import json
17+
import logging
18+
import time
1719
from dataclasses import dataclass, field
18-
from typing import Any, Iterable
20+
from typing import Any, Callable, Iterable, TypeVar
1921

2022
import httpx
2123

2224
from gooddata_eval.core.models import ChatResult, DatasetItem
2325

26+
_log = logging.getLogger(__name__)
27+
2428
SSE_DATA_PREFIX = "data: "
2529

30+
_RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 502, 503, 504})
31+
_METADATA_SYNC_MARKER = "METADATA_SYNC_IN_PROGRESS"
32+
33+
34+
class ChatError(RuntimeError):
35+
"""Non-retryable error reported by the chat SSE stream."""
36+
37+
def __init__(self, message: str, *, status_code: int | None = None, detail: str | None = None) -> None:
38+
super().__init__(message)
39+
self.status_code = status_code
40+
self.detail = detail
41+
42+
43+
class TransientChatError(ChatError):
44+
"""Retryable transient error: gen-ai temporarily unavailable or still syncing metadata."""
45+
46+
47+
_MAX_RETRIES = 5
48+
_INITIAL_BACKOFF_S = 5.0
49+
_BACKOFF_FACTOR = 2.0
50+
_MAX_BACKOFF_S = 60.0
51+
52+
T = TypeVar("T")
53+
54+
55+
def _is_retryable_exc(exc: Exception) -> bool:
56+
if isinstance(exc, TransientChatError):
57+
return True
58+
if isinstance(exc, httpx.HTTPStatusError):
59+
return exc.response.status_code in _RETRYABLE_STATUS_CODES
60+
return False
61+
62+
63+
def _retry_transient(operation: Callable[[], T], *, is_retryable: Callable[[Exception], bool]) -> T:
64+
"""Run ``operation``; retry retryable failures with bounded exponential backoff."""
65+
delay = _INITIAL_BACKOFF_S
66+
for attempt in range(_MAX_RETRIES + 1): # 0..N => N retries + 1 initial attempt
67+
try:
68+
return operation()
69+
except Exception as exc:
70+
if attempt == _MAX_RETRIES or not is_retryable(exc):
71+
raise
72+
sleep_s = min(delay, _MAX_BACKOFF_S)
73+
_log.warning(
74+
"Transient gen-ai error (attempt %d/%d): %s; retrying in %.0fs",
75+
attempt + 1,
76+
_MAX_RETRIES + 1,
77+
exc,
78+
sleep_s,
79+
)
80+
time.sleep(sleep_s)
81+
delay *= _BACKOFF_FACTOR
82+
raise AssertionError("unreachable") # loop either returns or raises
83+
2684

2785
@dataclass
2886
class _SseAccumulator:
@@ -114,12 +172,23 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
114172
if not line or line.startswith("event: ") or not line.startswith(SSE_DATA_PREFIX):
115173
continue
116174
data_str = line[len(SSE_DATA_PREFIX) :]
175+
if _METADATA_SYNC_MARKER in data_str:
176+
raise TransientChatError(
177+
f"SSE transient error: {_METADATA_SYNC_MARKER}",
178+
status_code=None,
179+
detail=None,
180+
)
117181
try:
118182
event_data = json.loads(data_str)
119183
except json.JSONDecodeError:
120184
continue
121185
if "statusCode" in event_data:
122-
raise RuntimeError(f"SSE error {event_data.get('statusCode')}: {event_data.get('detail')}")
186+
code = event_data.get("statusCode")
187+
detail = event_data.get("detail")
188+
message = f"SSE error {code}: {detail}"
189+
if code in _RETRYABLE_STATUS_CODES:
190+
raise TransientChatError(message, status_code=code, detail=detail)
191+
raise ChatError(message, status_code=code, detail=detail)
123192
item = event_data.get("item")
124193
if not item:
125194
continue
@@ -149,12 +218,17 @@ def __init__(self, host: str, token: str, workspace_id: str, *, timeout: float =
149218
self._client = httpx.Client(timeout=timeout)
150219

151220
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"]
221+
def _do() -> str:
222+
resp = self._client.post(self._base, headers={**self._auth, "Content-Type": "application/json"})
223+
resp.raise_for_status()
224+
body = resp.json()
225+
if "conversationId" not in body:
226+
raise ValueError(f"GoodData /chat/conversations response missing 'conversationId': {body}")
227+
return body["conversationId"]
228+
229+
# NOTE: retrying create is not idempotent — a created-then-503 can leak an
230+
# orphaned (ephemeral) conversation. Acceptable for eval; do not reuse blindly.
231+
return _retry_transient(_do, is_retryable=_is_retryable_exc)
158232

159233
def delete_conversation(self, conversation_id: str) -> None:
160234
try:
@@ -166,9 +240,13 @@ def send_message(self, conversation_id: str, question: str) -> ChatResult:
166240
url = f"{self._base}/{conversation_id}/messages"
167241
headers = {**self._auth, "Accept": "text/event-stream", "Content-Type": "application/json"}
168242
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())
243+
244+
def _do() -> ChatResult:
245+
with self._client.stream("POST", url, json=body, headers=headers) as resp:
246+
resp.raise_for_status()
247+
return parse_sse_lines(resp.iter_lines())
248+
249+
return _retry_transient(_do, is_retryable=_is_retryable_exc)
172250

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

packages/gooddata-eval/tests/test_sse_client.py

Lines changed: 121 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,121 @@ 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 == []

0 commit comments

Comments
 (0)