Skip to content

Commit c8d81ad

Browse files
authored
fix(sse): reconnect when a stream drops instead of reporting failure (#291)
## Summary A dropped operation stream ended `monitor_operation` as `FAILED` while the operation kept running, so a caller could retry a write that was still in progress. The client now reconnects and returns the operation's real result. ## Changes - **`SSEClient` and `AsyncSSEClient`:** - A mid-stream drop, a clean close before the operation finishes, and a `429` or `5xx` now go through the retry path. A deploy can cause any of these. - Other refusals, such as a `401`, still emit `error` without retrying. - `stream_end` still stops without reconnecting. - **Retry budget:** it now resets only when a real event arrives. The server sends `connected` on every open, so resetting on open or on that event would let a stream that opens and ends retry forever. - **`OperatorClient`:** it sets `max_retries=0`. Its `/status` polling already covers a lost stream, and retrying first would delay that by about 31 seconds. - **Connections:** a reconnect now releases the previous connection first. The async client also honors a `close()` during backoff. - **`clients/README.md`:** describes the new behavior. **Worth a close look:** a stream that keeps closing with no result used to raise `ended before a terminal event` straight away. It now retries, and when the retries run out it returns `FAILED` with the error, the same as other lost connections. A `stream_end` with no result, which is what a paused operation sends, still raises as before. ## Compatibility Fix only: no export or signature changes. Runtime behavior changes in the case above, and in `max_retries_exceeded` now firing for drops that used to emit `error`. Both correct false failures, so this fits a patch or minor, not a major. ## Testing - The pre-commit checks passed, including tests (747 passed, 17 skipped), with new coverage for drops, clean closes, 503s, refused credentials, `stream_end`, the retry budget and the async client. - Reproduced before and after against a local mock server that drops the first connection: `failed` after 1 connection before, `completed` after 2 connections with the fix. - Not run against a live API or through a real deploy. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 0727848 + 7d6eb8b commit c8d81ad

5 files changed

Lines changed: 277 additions & 19 deletions

File tree

‎robosystems_client/clients/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,9 @@ result = clients.monitor_operation(operation_id, on_progress)
114114
print(result.status.value, result.result, result.error)
115115
```
116116

117-
`status` is an `OperationStatus`. `FAILED` can also mean the stream connection failed while the operation was still running, with the transport error in `error`. `get_operation_status(operation_id)` returns the server's view as a dict, and `cancel_operation(operation_id)` returns a bool. To set a limit, call `clients.operations.monitor_operation(operation_id, MonitorOptions(timeout=600))`, which raises `TimeoutError`.
117+
`status` is an `OperationStatus`. A dropped stream reconnects, so a brief outage doesn't change the result. `FAILED` can still mean the reconnects ran out while the operation was running, with the transport error in `error`. `get_operation_status(operation_id)` returns the server's view as a dict, and `cancel_operation(operation_id)` returns a bool. To set a limit, call `clients.operations.monitor_operation(operation_id, MonitorOptions(timeout=600))`, which raises `TimeoutError`.
118118

119-
For custom event handling, use `create_sse_client()`, which copies the configured headers. An `SSEConfig` you build yourself needs credentials in `headers`, because the stream endpoint returns 401 without them. `connect` reports problems through the `error` event rather than raising. It retries with backoff only when opening the connection raises; a non-200 response or a drop mid-stream emits `error` and returns.
119+
For custom event handling, use `create_sse_client()`, which copies the configured headers. An `SSEConfig` you build yourself needs credentials in `headers`, because the stream endpoint returns 401 without them. `connect` reports problems through events rather than raising. A dropped stream, a clean close before the operation finishes, or a `429` or `5xx` response reconnects with backoff at the original `from_sequence`, so a running operation's events during the gap are lost and a finished one replays. The attempts reset only when a real event arrives, and `max_retries_exceeded` fires when they run out. Any other non-200 response emits `error` and returns. `stream_end` ends the connection without a reconnect.
120120

121121
```python
122122
from robosystems_client.clients import EventType

‎robosystems_client/clients/operator_client.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,13 @@ def _rest_client(self) -> Client:
246246

247247
def _sse_config(self) -> SSEConfig:
248248
"""Stream config for one connect; headers carry the credential current now."""
249-
return SSEConfig(base_url=self.base_url, headers=resolve_auth_headers(self.config))
249+
# No stream retries: `/status` polling already covers a lost stream, and
250+
# retrying first would only delay it.
251+
return SSEConfig(
252+
base_url=self.base_url,
253+
headers=resolve_auth_headers(self.config),
254+
max_retries=0,
255+
)
250256

251257
def execute_query(
252258
self,

‎robosystems_client/clients/sse_client.py‎

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ class EventType(Enum):
7474
QUEUE_UPDATE = "queue_update"
7575

7676

77+
# Sent on every open and while idle, so neither shows the stream got anywhere.
78+
_NO_PROGRESS_EVENTS = {"connected", "keepalive"}
79+
80+
81+
class _StreamInterrupted(Exception):
82+
"""The stream ended or failed before the server said it was finished."""
83+
84+
85+
def _is_retryable_status(status: int) -> bool:
86+
return status == 429 or status >= 500
87+
88+
7789
def event_error_message(err: Any) -> str:
7890
"""Text of an ``error`` payload, whichever shape the stream emitted.
7991
@@ -104,6 +116,7 @@ def __init__(self, config: SSEConfig) -> None:
104116
self.closed = False
105117
self.listeners: Dict[str, Set[Callable]] = {}
106118
self._response = None
119+
self._stream_ended = False
107120

108121
def connect(self, operation_id: str, from_sequence: int = 0) -> None:
109122
"""Connect to SSE stream for the given operation"""
@@ -126,6 +139,10 @@ def connect(self, operation_id: str, from_sequence: int = 0) -> None:
126139
if self._response.status_code != 200:
127140
status = self._response.status_code
128141
body = self._response.read().decode("utf-8", errors="replace")[:500]
142+
if _is_retryable_status(status):
143+
raise _StreamInterrupted(
144+
f"SSE connection failed: HTTP {status} {body}".strip()
145+
)
129146
self._context_manager.__exit__(None, None, None)
130147
self._context_manager = None
131148
self._response = None
@@ -136,12 +153,15 @@ def connect(self, operation_id: str, from_sequence: int = 0) -> None:
136153
)
137154
return
138155

139-
self.reconnect_attempts = 0
140156
self.emit("connected", None)
141157

142158
# Start processing events
143159
self._process_events()
144160

161+
# A deploy can close the stream cleanly while the operation runs on.
162+
if not self.closed and not self._stream_ended:
163+
raise _StreamInterrupted("SSE stream ended before the operation finished")
164+
145165
except Exception as error:
146166
if not self.closed:
147167
self._handle_error(error, operation_id, from_sequence)
@@ -200,9 +220,9 @@ def _process_events(self) -> None:
200220
if event_buffer["data"] or event_buffer["event"]:
201221
self._dispatch_event(event_buffer)
202222

203-
except Exception as error:
223+
except Exception:
204224
if not self.closed:
205-
self.emit("error", error)
225+
raise
206226

207227
def _dispatch_event(self, event_buffer: Dict[str, Any]) -> None:
208228
"""Dispatch a complete SSE event"""
@@ -214,6 +234,11 @@ def _dispatch_event(self, event_buffer: Dict[str, Any]) -> None:
214234

215235
event_type = event_buffer["event"] or "message"
216236

237+
if event_type == "stream_end":
238+
self._stream_ended = True
239+
if event_type not in _NO_PROGRESS_EVENTS:
240+
self.reconnect_attempts = 0
241+
217242
# Parse JSON data if possible
218243
parsed_data = data_str
219244
try:
@@ -252,6 +277,8 @@ def _handle_error(
252277
if self.closed:
253278
return
254279

280+
self._release_transport()
281+
255282
if self.reconnect_attempts < self.config.max_retries:
256283
self.reconnect_attempts += 1
257284
delay_ms = self.config.retry_delay * (2 ** (self.reconnect_attempts - 1))
@@ -308,10 +335,8 @@ def emit(self, event: str, data: Any) -> None:
308335
# Log error but don't stop other listeners
309336
print(f"Error in event listener for {event}: {e}")
310337

311-
def close(self):
312-
"""Close the SSE connection"""
313-
self.closed = True
314-
338+
def _release_transport(self) -> None:
339+
"""Drop the current connection, leaving the client able to reconnect."""
315340
if hasattr(self, "_context_manager") and self._context_manager:
316341
try:
317342
self._context_manager.__exit__(None, None, None)
@@ -324,6 +349,10 @@ def close(self):
324349
self.client.close()
325350
self.client = None
326351

352+
def close(self):
353+
"""Close the SSE connection"""
354+
self.closed = True
355+
self._release_transport()
327356
self.emit("closed", None)
328357
self.listeners.clear()
329358

@@ -348,6 +377,7 @@ def __init__(self, config: SSEConfig) -> None:
348377
self.closed = False
349378
self.listeners: Dict[str, Set[Callable]] = {}
350379
self._response = None
380+
self._stream_ended = False
351381

352382
async def connect(self, operation_id: str, from_sequence: int = 0) -> None:
353383
"""Connect to SSE stream for the given operation (async)"""
@@ -370,6 +400,10 @@ async def connect(self, operation_id: str, from_sequence: int = 0) -> None:
370400
if self._response.status_code != 200:
371401
status = self._response.status_code
372402
body = (await self._response.aread()).decode("utf-8", errors="replace")[:500]
403+
if _is_retryable_status(status):
404+
raise _StreamInterrupted(
405+
f"SSE connection failed: HTTP {status} {body}".strip()
406+
)
373407
await self._context_manager.__aexit__(None, None, None)
374408
self._context_manager = None
375409
self._response = None
@@ -380,12 +414,15 @@ async def connect(self, operation_id: str, from_sequence: int = 0) -> None:
380414
)
381415
return
382416

383-
self.reconnect_attempts = 0
384417
self.emit("connected", None)
385418

386419
# Start processing events
387420
await self._process_events()
388421

422+
# A deploy can close the stream cleanly while the operation runs on.
423+
if not self.closed and not self._stream_ended:
424+
raise _StreamInterrupted("SSE stream ended before the operation finished")
425+
389426
except Exception as error:
390427
if not self.closed:
391428
await self._handle_error(error, operation_id, from_sequence)
@@ -444,9 +481,9 @@ async def _process_events(self) -> None:
444481
if event_buffer["data"] or event_buffer["event"]:
445482
self._dispatch_event(event_buffer)
446483

447-
except Exception as error:
484+
except Exception:
448485
if not self.closed:
449-
self.emit("error", error)
486+
raise
450487

451488
def _dispatch_event(self, event_buffer: Dict[str, Any]):
452489
"""Dispatch a complete SSE event (same as sync version)"""
@@ -458,6 +495,11 @@ def _dispatch_event(self, event_buffer: Dict[str, Any]):
458495

459496
event_type = event_buffer["event"] or "message"
460497

498+
if event_type == "stream_end":
499+
self._stream_ended = True
500+
if event_type not in _NO_PROGRESS_EVENTS:
501+
self.reconnect_attempts = 0
502+
461503
# Parse JSON data if possible
462504
parsed_data = data_str
463505
try:
@@ -496,6 +538,8 @@ async def _handle_error(
496538
if self.closed:
497539
return
498540

541+
await self._release_transport()
542+
499543
if self.reconnect_attempts < self.config.max_retries:
500544
self.reconnect_attempts += 1
501545
delay_ms = self.config.retry_delay * (2 ** (self.reconnect_attempts - 1))
@@ -512,6 +556,10 @@ async def _handle_error(
512556

513557
await asyncio.sleep(delay_seconds)
514558

559+
# A close() during the backoff ends the reconnect.
560+
if self.closed:
561+
return
562+
515563
resume_from = 0
516564
if self.last_event_id:
517565
try:
@@ -546,10 +594,8 @@ def emit(self, event: str, data: Any) -> None:
546594
except Exception as e:
547595
print(f"Error in event listener for {event}: {e}")
548596

549-
async def close(self):
550-
"""Close the SSE connection (async)"""
551-
self.closed = True
552-
597+
async def _release_transport(self) -> None:
598+
"""Drop the current connection, leaving the client able to reconnect."""
553599
if hasattr(self, "_context_manager") and self._context_manager:
554600
try:
555601
await self._context_manager.__aexit__(None, None, None)
@@ -562,6 +608,10 @@ async def close(self):
562608
await self.client.aclose()
563609
self.client = None
564610

611+
async def close(self):
612+
"""Close the SSE connection (async)"""
613+
self.closed = True
614+
await self._release_transport()
565615
self.emit("closed", None)
566616
self.listeners.clear()
567617

‎tests/test_facade_wire_shapes.py‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import json
1010
import re
1111

12+
from unittest.mock import patch
13+
1214
import pytest
1315

1416
from robosystems_client.clients import OperatorRunError
@@ -442,17 +444,48 @@ def test_cancelled_event_settles_the_monitor(self, httpx_mock):
442444

443445
assert result.status == OperationStatus.CANCELLED
444446

445-
def test_stream_ending_without_a_verdict_raises(self, httpx_mock):
447+
def test_stream_end_without_a_verdict_raises(self, httpx_mock):
446448
httpx_mock.add_response(
447449
url=_stream_url(),
448450
content=_sse(
449-
("operation_progress", {"message": "Working", "progress_percent": 5})
451+
("operation_awaiting_input", {"prompt": "Confirm?"}),
452+
("stream_end", {"status": "awaiting_input"}),
450453
),
451454
)
452455

453456
with pytest.raises(Exception, match="ended before a terminal event"):
454457
OperationClient(CONFIG).monitor_operation(OP)
455458

459+
def test_a_stream_that_keeps_closing_gives_up_after_its_retries(self, httpx_mock):
460+
httpx_mock.add_response(
461+
url=_stream_url(),
462+
content=_sse(("connected", {})),
463+
is_reusable=True,
464+
)
465+
466+
with patch("robosystems_client.clients.sse_client.time.sleep"):
467+
result = OperationClient(CONFIG).monitor_operation(OP)
468+
469+
assert result.status == OperationStatus.FAILED
470+
assert "ended before the operation finished" in result.error
471+
assert len(httpx_mock.get_requests()) == 6
472+
473+
def test_a_closed_stream_reconnects_and_reaches_the_verdict(self, httpx_mock):
474+
httpx_mock.add_response(
475+
url=_stream_url(),
476+
content=_sse(("operation_progress", {"message": "Working"})),
477+
)
478+
httpx_mock.add_response(
479+
url=_stream_url(),
480+
content=_sse(("operation_completed", {"result": {"ok": True}})),
481+
)
482+
483+
with patch("robosystems_client.clients.sse_client.time.sleep"):
484+
result = OperationClient(CONFIG).monitor_operation(OP)
485+
486+
assert result.status == OperationStatus.COMPLETED
487+
assert result.result == {"ok": True}
488+
456489
def test_progress_reads_progress_percent(self, httpx_mock):
457490
httpx_mock.add_response(
458491
url=_stream_url(),

0 commit comments

Comments
 (0)