Skip to content

Commit 9f97835

Browse files
authored
Merge pull request #1752 from gooddata/fix/retry-remote-protocol-error
Fix: retry httpx.RemoteProtocolError in the SSE chat client
2 parents bf0e858 + ef68e5b commit 9f97835

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ def _is_retryable_exc(exc: Exception) -> bool:
8989
return True
9090
if isinstance(exc, httpx.HTTPStatusError):
9191
return exc.response.status_code in _RETRYABLE_STATUS_CODES
92+
if isinstance(exc, httpx.RemoteProtocolError):
93+
# Mid-stream disconnect ("peer closed connection without sending complete
94+
# message body") -- pure network flake, not a real agent/content failure.
95+
# Confirmed live: contaminated ~1-4% of visualization runs with a hard
96+
# fail and zero retry attempts.
97+
return True
9298
return False
9399

94100

@@ -223,7 +229,14 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
223229
# Only a transport-level failure (e.g. connection drop mid-stream) is rescued
224230
# here -- a bug in the processing below must propagate uncaught, not get
225231
# mislabeled as a network error.
226-
raise ChatError(f"SSE stream error: {exc}", partial_result=_build_chat_result(acc)) from exc
232+
partial = _build_chat_result(acc)
233+
if isinstance(exc, httpx.RemoteProtocolError):
234+
# Same mid-stream disconnect _is_retryable_exc already retries when it happens
235+
# at connect time -- here it surfaces from `next(it)` instead, so it must be
236+
# raised as TransientChatError or the wrapping below would mask it as
237+
# non-retryable and defeat the retry this class exists for.
238+
raise TransientChatError(f"SSE stream error: {exc}", partial_result=partial) from exc
239+
raise ChatError(f"SSE stream error: {exc}", partial_result=partial) from exc
227240
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
228241
if not line:
229242
current_event = "message" # blank line ends one event block per the SSE spec

packages/gooddata-eval/tests/test_sse_client.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ def _lines():
8585
assert partial.tool_call_events[0].function_name == "create_key_driver_analysis"
8686

8787

88+
def test_parse_sse_lines_remote_protocol_error_mid_stream_is_retryable():
89+
# httpx.RemoteProtocolError raised from `next(it)` (mid-stream, not at connect time) must
90+
# come out as TransientChatError -- otherwise _is_retryable_exc never sees the raw
91+
# RemoteProtocolError (only the ChatError parse_sse_lines wraps it in) and the retry this
92+
# class exists for never fires. Same partial_result guarantee as any other transport error.
93+
def _lines():
94+
yield (
95+
'data: {"item": {"role": "assistant", "content": '
96+
+ json.dumps({"type": "toolCall", "callId": "c1", "name": "create_key_driver_analysis"})
97+
+ "}}"
98+
)
99+
yield ""
100+
raise httpx.RemoteProtocolError("peer closed connection without sending complete message body")
101+
102+
with pytest.raises(TransientChatError) as ei:
103+
parse_sse_lines(_lines())
104+
partial = ei.value.partial_result
105+
assert partial is not None
106+
assert len(partial.tool_call_events) == 1
107+
assert partial.tool_call_events[0].function_name == "create_key_driver_analysis"
108+
109+
88110
def test_parse_sse_lines_a_real_parsing_bug_propagates_uncaught_not_as_a_chat_error():
89111
# A malformed payload (here: "item" is a string, not a dict) crashes the processing
90112
# code itself with a plain AttributeError -- must surface loudly as that bug, not get
@@ -380,6 +402,26 @@ def handler(request):
380402
assert sleeps == [5, 10]
381403

382404

405+
def test_create_conversation_retries_remote_protocol_error(monkeypatch):
406+
# "peer closed connection without sending complete message body" -- a pure
407+
# network flake, not a real agent/content failure. Previously not retried
408+
# at all: hard-failed on the first occurrence with zero retry attempts.
409+
sleeps = []
410+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))
411+
calls = {"n": 0}
412+
413+
def handler(request):
414+
calls["n"] += 1
415+
if calls["n"] < 3:
416+
raise httpx.RemoteProtocolError("peer closed connection without sending complete message body")
417+
return httpx.Response(200, json={"conversationId": "abc"})
418+
419+
client = _client_with_handler(handler)
420+
assert client.create_conversation() == "abc"
421+
assert calls["n"] == 3
422+
assert sleeps == [5, 10]
423+
424+
383425
def test_create_conversation_does_not_retry_4xx(monkeypatch):
384426
sleeps = []
385427
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: sleeps.append(s))

0 commit comments

Comments
 (0)