Skip to content

Commit 48a6a63

Browse files
OmarAlJarrahclaude
andcommitted
fix(sse): chain the underlying transport error on reconnect-budget exhaustion
When the reconnect budget was exhausted, both the sync and async SSE clients raised a bare ServiceResponseError("SSE reconnect budget exhausted"), discarding the last transient transport error that caused the final reconnect. The error was caught and swallowed mid-stream, leaving callers without the root cause. Capture the last transient error caught while streaming and chain it as the exception cause (raise ... from last_error) when the budget is hit. The message and exception type are unchanged, so existing matchers still pass; the cause now carries the original transport failure for diagnosis. Also adds the spec-listed tests the suite was missing: empty-id clearing of the replay header, upward-only backoff jitter, async clean-EOF reconnect, and async retry-hint-then-budget exhaustion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 808f866 commit 48a6a63

3 files changed

Lines changed: 120 additions & 21 deletions

File tree

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/connection.py

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,18 @@ def __exit__(
186186

187187
def __iter__(self) -> Iterator[SseEvent]:
188188
failures = 0
189+
last_error: BaseException | None = None
189190
try:
190191
while True:
191192
response = self._connect(_resume_request(self._initial, self._last_event_id))
192-
progressed = yield from self._stream(response)
193+
progressed, error = yield from self._stream(response)
194+
if error is not None:
195+
last_error = error
193196
self.close()
194197
if progressed:
195198
failures = 0
196199
if self._max_reconnects is not None and failures >= self._max_reconnects:
197-
raise ServiceResponseError("SSE reconnect budget exhausted")
200+
raise ServiceResponseError("SSE reconnect budget exhausted") from last_error
198201
self._clock.sleep(
199202
_next_backoff(
200203
self._retry_base, failures, self._max_backoff, self._jitter, self._rand
@@ -212,22 +215,22 @@ def _connect(self, request: Request) -> Response:
212215
raise HttpResponseError(response=response)
213216
return response
214217

215-
def _stream(self, response: Response) -> Generator[SseEvent, None, bool]:
216-
"""Yield one connection's events; return whether any were yielded.
218+
def _stream(
219+
self, response: Response
220+
) -> Generator[SseEvent, None, tuple[bool, BaseException | None]]:
221+
"""Yield one connection's events; return (progressed, transient_error).
217222
218-
A transient transport error mid-stream ends the generator normally
219-
(so the caller reconnects); other exceptions propagate.
220-
221-
``retry:`` directives that arrive without an accompanying ``data:``
222-
field (so no ``SseEvent`` is emitted) are still picked up from the
223-
parser's accumulated state after iteration ends, ensuring the
224-
server-supplied reconnect delay is honoured even on pure
225-
retry-hint frames.
223+
A transient transport error mid-stream ends the generator normally (so
224+
the caller reconnects) and is returned so the caller can chain it as the
225+
cause if the reconnect budget is later exhausted; other exceptions
226+
propagate. A ``retry:`` hint that arrived without a data event is picked
227+
up from the parser's sticky state after iteration.
226228
"""
227229
progressed = False
230+
error: BaseException | None = None
228231
body = response.body
229232
if body is None:
230-
return progressed
233+
return progressed, error
231234
parser = SseParser()
232235
try:
233236
for chunk in body.iter_bytes():
@@ -240,13 +243,11 @@ def _stream(self, response: Response) -> Generator[SseEvent, None, bool]:
240243
self._last_event_id = _last_event_id_of(event, self._last_event_id)
241244
progressed = True
242245
yield event
243-
except _TRANSIENT:
244-
pass
245-
# Capture the server's sticky reconnect hint (including a retry:-only frame
246-
# that emitted no event) for the next reconnection's backoff base.
246+
except _TRANSIENT as exc:
247+
error = exc
247248
if parser.retry is not None:
248249
self._retry_base = parser.retry / 1000.0
249-
return progressed
250+
return progressed, error
250251

251252

252253
class AsyncSseConnection:
@@ -350,6 +351,7 @@ def __aiter__(self) -> AsyncIterator[SseEvent]:
350351

351352
async def _events(self) -> AsyncIterator[SseEvent]:
352353
failures = 0
354+
last_error: BaseException | None = None
353355
try:
354356
while True:
355357
response = await self._connect(_resume_request(self._initial, self._last_event_id))
@@ -363,15 +365,15 @@ async def _events(self) -> AsyncIterator[SseEvent]:
363365
self._last_event_id = _last_event_id_of(event, self._last_event_id)
364366
progressed = True
365367
yield event
366-
except _TRANSIENT:
367-
pass
368+
except _TRANSIENT as exc:
369+
last_error = exc
368370
if stream.retry is not None:
369371
self._retry_base = stream.retry / 1000.0
370372
await self.aclose()
371373
if progressed:
372374
failures = 0
373375
if self._max_reconnects is not None and failures >= self._max_reconnects:
374-
raise ServiceResponseError("SSE reconnect budget exhausted")
376+
raise ServiceResponseError("SSE reconnect budget exhausted") from last_error
375377
await self._clock.sleep(
376378
_next_backoff(
377379
self._retry_base, failures, self._max_backoff, self._jitter, self._rand

packages/dexpace-sdk-core/tests/sse/test_async_connection.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,37 @@ async def consume() -> None:
145145

146146
assert seen == ["hi"]
147147
assert body.closed is True
148+
149+
150+
async def test_async_reconnects_after_clean_eof() -> None:
151+
first = _AsyncDropBody([b"data: a\n\n"])
152+
second = _AsyncDropBody([b"data: b\n\n"])
153+
script = _AsyncScript([_response(first), _response(second)])
154+
conn = AsyncSseConnection(script, _request(), clock=_RecordingAsyncClock(), rand=_LowJitter())
155+
156+
received: list[str] = []
157+
async with conn as events:
158+
async for event in events:
159+
received.append(event.data)
160+
if len(received) == 2:
161+
break
162+
163+
assert received == ["a", "b"]
164+
assert first.closed is True
165+
166+
167+
async def test_async_honours_retry_then_budget() -> None:
168+
retry_then_drop = _response(
169+
_AsyncDropBody([b"retry: 1000\n\n"], error=ServiceResponseError("x"))
170+
)
171+
drop1 = _response(_AsyncDropBody([], error=ServiceResponseError("x")))
172+
drop2 = _response(_AsyncDropBody([], error=ServiceResponseError("x")))
173+
script = _AsyncScript([retry_then_drop, drop1, drop2])
174+
clock = _RecordingAsyncClock()
175+
conn = AsyncSseConnection(script, _request(), clock=clock, rand=_LowJitter(), max_reconnects=2)
176+
177+
with pytest.raises(ServiceResponseError, match="reconnect budget"):
178+
async for _ in conn:
179+
pass
180+
181+
assert clock.sleeps == [1.0, 2.0]

packages/dexpace-sdk-core/tests/sse/test_connection.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ def uniform(self, a: float, b: float) -> float:
9090
return a
9191

9292

93+
class _HighJitter(random.Random):
94+
"""``uniform`` returns its high bound — exercises upward jitter."""
95+
96+
def uniform(self, a: float, b: float) -> float:
97+
return b
98+
99+
93100
def test_yields_events_and_closes_on_caller_stop() -> None:
94101
body = _DropBody([b"data: one\n\n", b"data: two\n\n"])
95102
script = _Script([_response(body)])
@@ -190,3 +197,59 @@ def test_non_success_status_raises_without_reconnect() -> None:
190197

191198
# No reconnect attempted: send called exactly once.
192199
assert len(script.requests) == 1
200+
201+
202+
def test_empty_id_clears_replay_header() -> None:
203+
# id:5 then an explicit empty id (clears), so the reconnect omits the header.
204+
first = _DropBody([b"id: 5\ndata: a\n\nid:\ndata: b\n\n"], error=ServiceResponseError("x"))
205+
second = _DropBody([b"data: c\n\n"])
206+
script = _Script([_response(first), _response(second)])
207+
conn = SseConnection(script, _request(), clock=_RecordingClock(), rand=_LowJitter())
208+
209+
received: list[str] = []
210+
with conn as events:
211+
for event in events:
212+
received.append(event.data)
213+
if len(received) == 3:
214+
break
215+
216+
assert received == ["a", "b", "c"]
217+
assert script.requests[1].headers.get("last-event-id") is None
218+
219+
220+
def test_backoff_jitter_is_upward() -> None:
221+
first = _DropBody([b"data: a\n\n"], error=ServiceResponseError("x"))
222+
second = _DropBody([b"data: b\n\n"])
223+
script = _Script([_response(first), _response(second)])
224+
clock = _RecordingClock()
225+
conn = SseConnection(script, _request(), clock=clock, rand=_HighJitter(), jitter=0.1)
226+
227+
received: list[str] = []
228+
with conn as events:
229+
for event in events:
230+
received.append(event.data)
231+
if len(received) == 2:
232+
break
233+
234+
# First connection progressed -> failures reset to 0 -> base 3.0; high
235+
# jitter multiplies by 1.0 + 0.1, never below the base.
236+
assert clock.sleeps == [pytest.approx(3.3)]
237+
assert clock.sleeps[0] > 3.0
238+
239+
240+
def test_budget_exhaustion_chains_last_transport_error() -> None:
241+
boom = ServiceResponseError("connection reset")
242+
script = _Script(
243+
[
244+
_response(_DropBody([], error=boom)),
245+
_response(_DropBody([], error=boom)),
246+
]
247+
)
248+
conn = SseConnection(
249+
script, _request(), clock=_RecordingClock(), rand=_LowJitter(), max_reconnects=1
250+
)
251+
252+
with pytest.raises(ServiceResponseError, match="reconnect budget") as exc_info:
253+
for _ in conn:
254+
pass
255+
assert exc_info.value.__cause__ is boom

0 commit comments

Comments
 (0)