Skip to content

Commit a5c3200

Browse files
OmarAlJarrahclaude
andcommitted
feat: add asynchronous reconnecting SSE connection
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 92f1f5e commit a5c3200

3 files changed

Lines changed: 304 additions & 4 deletions

File tree

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

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,23 @@
2222

2323
from ...errors import HttpResponseError, ServiceRequestError, ServiceResponseError
2424
from ...pipeline.dispatch import (
25+
AsyncPipelineLike,
26+
SendAsync,
2527
SendSync,
2628
SyncPipelineLike,
2729
)
28-
from ...util.clock import SYSTEM_CLOCK, Clock
30+
from ...util.clock import ASYNC_SYSTEM_CLOCK, SYSTEM_CLOCK, AsyncClock, Clock
2931
from ..context.dispatch_context import DispatchContext
30-
from .parser import SseParser
32+
from ..response.async_response_body import _shielded_cleanup
33+
from .parser import SseParser, parse_async_events
3134

3235
if TYPE_CHECKING:
33-
from collections.abc import Callable, Generator, Iterator
36+
from collections.abc import AsyncIterator, Callable, Generator, Iterator
3437
from types import TracebackType
3538
from typing import Self
3639

3740
from ..request.request import Request
41+
from ..response.async_response import AsyncResponse
3842
from ..response.response import Response
3943
from .parser import SseEvent
4044

@@ -245,4 +249,145 @@ def _stream(self, response: Response) -> Generator[SseEvent, None, bool]:
245249
return progressed
246250

247251

248-
__all__ = ["SseConnection"]
252+
class AsyncSseConnection:
253+
"""Asynchronous twin of :class:`SseConnection`.
254+
255+
Mirrors the sync client exactly with ``async`` iteration semantics. Each
256+
(re)connection is dispatched through ``source`` — an ``AsyncPipeline`` (run
257+
with a fresh dispatch context per connection) or an async
258+
``Request -> AsyncResponse`` callable. The response is closed through the
259+
shielded-cleanup convention, so a cancelled consumer still releases the
260+
transport handle before the cancellation continues to propagate.
261+
262+
Args:
263+
source: An async pipeline or an async send-callable.
264+
initial_request: The request opening the stream; reused for every
265+
reconnection with an updated ``Last-Event-ID``.
266+
last_event_id: Seed id to resume a previously-interrupted stream.
267+
default_retry: Backoff base (seconds) used until the server sends a
268+
``retry:`` value.
269+
max_backoff: Ceiling on any single reconnect delay.
270+
max_reconnects: Maximum consecutive failed reconnections before
271+
iteration raises. ``None`` reconnects indefinitely.
272+
jitter: Upward jitter fraction applied to the backoff.
273+
clock: Async time source for backoff sleeps (injected for tests).
274+
rand: RNG for jitter (injected for tests).
275+
dispatch_factory: Builds the dispatch context per connection when
276+
``source`` is a pipeline. Defaults to ``DispatchContext.noop``.
277+
"""
278+
279+
__slots__ = (
280+
"_clock",
281+
"_dispatch_factory",
282+
"_initial",
283+
"_jitter",
284+
"_last_event_id",
285+
"_max_backoff",
286+
"_max_reconnects",
287+
"_rand",
288+
"_response",
289+
"_retry_base",
290+
"_send",
291+
)
292+
293+
def __init__(
294+
self,
295+
source: AsyncPipelineLike | SendAsync,
296+
initial_request: Request,
297+
*,
298+
last_event_id: str | None = None,
299+
default_retry: float = _DEFAULT_RETRY_SECONDS,
300+
max_backoff: float = _DEFAULT_MAX_BACKOFF,
301+
max_reconnects: int | None = None,
302+
jitter: float = _DEFAULT_JITTER,
303+
clock: AsyncClock = ASYNC_SYSTEM_CLOCK,
304+
rand: random.Random | None = None,
305+
dispatch_factory: Callable[[], DispatchContext] | None = None,
306+
) -> None:
307+
self._initial = initial_request
308+
self._last_event_id = last_event_id
309+
self._retry_base = default_retry
310+
self._max_backoff = max_backoff
311+
self._max_reconnects = max_reconnects
312+
self._jitter = jitter
313+
self._clock = clock
314+
self._rand = rand if rand is not None else random.Random()
315+
self._dispatch_factory = dispatch_factory or DispatchContext.noop
316+
self._response: AsyncResponse | None = None
317+
self._send = self._normalise(source)
318+
319+
def _normalise(self, source: AsyncPipelineLike | SendAsync) -> SendAsync:
320+
if isinstance(source, AsyncPipelineLike):
321+
pipeline = source
322+
323+
async def send(request: Request) -> AsyncResponse:
324+
return await pipeline.run(request, self._dispatch_factory())
325+
326+
return send
327+
return source
328+
329+
async def aclose(self) -> None:
330+
"""Close the current response, if any. Idempotent and cancel-safe."""
331+
response = self._response
332+
if response is None:
333+
return
334+
self._response = None
335+
await _shielded_cleanup(response.close())
336+
337+
async def __aenter__(self) -> Self:
338+
return self
339+
340+
async def __aexit__(
341+
self,
342+
exc_type: type[BaseException] | None,
343+
exc: BaseException | None,
344+
tb: TracebackType | None,
345+
) -> None:
346+
await self.aclose()
347+
348+
def __aiter__(self) -> AsyncIterator[SseEvent]:
349+
return self._events()
350+
351+
async def _events(self) -> AsyncIterator[SseEvent]:
352+
failures = 0
353+
try:
354+
while True:
355+
response = await self._connect(_resume_request(self._initial, self._last_event_id))
356+
progressed = False
357+
body = response.body
358+
if body is not None:
359+
stream = parse_async_events(body.aiter_bytes())
360+
try:
361+
async with stream:
362+
async for event in stream:
363+
self._last_event_id = _last_event_id_of(event, self._last_event_id)
364+
progressed = True
365+
yield event
366+
except _TRANSIENT:
367+
pass
368+
if stream.retry is not None:
369+
self._retry_base = stream.retry / 1000.0
370+
await self.aclose()
371+
if progressed:
372+
failures = 0
373+
if self._max_reconnects is not None and failures >= self._max_reconnects:
374+
raise ServiceResponseError("SSE reconnect budget exhausted")
375+
await self._clock.sleep(
376+
_next_backoff(
377+
self._retry_base, failures, self._max_backoff, self._jitter, self._rand
378+
)
379+
)
380+
failures += 1
381+
finally:
382+
await self.aclose()
383+
384+
async def _connect(self, request: Request) -> AsyncResponse:
385+
response = await self._send(request)
386+
self._response = response
387+
if not response.status.is_success:
388+
await self.aclose()
389+
raise HttpResponseError(response=response)
390+
return response
391+
392+
393+
__all__ = ["AsyncSseConnection", "SseConnection"]

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,14 @@ async def __anext__(self) -> SseEvent:
331331
self._parser.feed(chunk)
332332
self._pending = self._parser.drain()
333333

334+
@property
335+
def retry(self) -> int | None:
336+
"""The most recent ``retry:`` value seen by the underlying parser, in
337+
milliseconds, or ``None``. Mirrors :attr:`SseParser.retry` so a
338+
reconnecting client can read the server's sticky hint after iteration.
339+
"""
340+
return self._parser.retry
341+
334342
async def aclose(self) -> None:
335343
"""Release the upstream byte stream. Idempotent.
336344
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
# Licensed under the MIT License. See LICENSE.md in the repository root for details.
3+
4+
"""Tests for the asynchronous reconnecting SSE client."""
5+
6+
from __future__ import annotations
7+
8+
import asyncio
9+
import random
10+
from collections.abc import AsyncIterator
11+
12+
import pytest
13+
14+
from dexpace.sdk.core.errors import HttpResponseError, ServiceResponseError
15+
from dexpace.sdk.core.http.common import MediaType, Protocol, Url
16+
from dexpace.sdk.core.http.request import Method, Request
17+
from dexpace.sdk.core.http.response import AsyncResponse, AsyncResponseBody, Status
18+
from dexpace.sdk.core.http.sse.connection import AsyncSseConnection
19+
20+
21+
def _request() -> Request:
22+
return Request(method=Method.GET, url=Url.parse("https://api.example.com/stream"))
23+
24+
25+
class _AsyncDropBody(AsyncResponseBody):
26+
"""Yields scripted chunks, optionally then raises, optionally blocks."""
27+
28+
def __init__(
29+
self,
30+
chunks: list[bytes],
31+
*,
32+
error: BaseException | None = None,
33+
block: bool = False,
34+
) -> None:
35+
self._chunks = chunks
36+
self._error = error
37+
self._block = block
38+
self.closed = False
39+
40+
def media_type(self) -> MediaType | None:
41+
return MediaType.parse("text/event-stream")
42+
43+
def content_length(self) -> int:
44+
return -1
45+
46+
async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]:
47+
for chunk in self._chunks:
48+
yield chunk
49+
if self._block:
50+
await asyncio.Event().wait() # park until cancelled
51+
if self._error is not None:
52+
raise self._error
53+
54+
async def close(self) -> None:
55+
self.closed = True
56+
57+
58+
def _response(body: AsyncResponseBody | None, *, status: Status = Status.OK) -> AsyncResponse:
59+
return AsyncResponse(
60+
request=_request(),
61+
protocol=Protocol.HTTP_1_1,
62+
status=status,
63+
body=body,
64+
)
65+
66+
67+
class _AsyncScript:
68+
def __init__(self, responses: list[AsyncResponse]) -> None:
69+
self._responses = list(responses)
70+
self.requests: list[Request] = []
71+
72+
async def __call__(self, request: Request) -> AsyncResponse:
73+
self.requests.append(request)
74+
if not self._responses:
75+
raise AssertionError("send called more times than scripted")
76+
return self._responses.pop(0)
77+
78+
79+
class _RecordingAsyncClock:
80+
def __init__(self) -> None:
81+
self.sleeps: list[float] = []
82+
83+
def now(self) -> float:
84+
return 0.0
85+
86+
def monotonic(self) -> float:
87+
return 0.0
88+
89+
async def sleep(self, duration: float) -> None:
90+
self.sleeps.append(duration)
91+
92+
93+
class _LowJitter(random.Random):
94+
def uniform(self, a: float, b: float) -> float:
95+
return a
96+
97+
98+
async def test_async_yields_events_and_reconnects_with_replay() -> None:
99+
dropped = _AsyncDropBody([b"id: 9\ndata: one\n\n"], error=ServiceResponseError("drop"))
100+
resumed = _AsyncDropBody([b"data: two\n\n"])
101+
script = _AsyncScript([_response(dropped), _response(resumed)])
102+
conn = AsyncSseConnection(script, _request(), clock=_RecordingAsyncClock(), rand=_LowJitter())
103+
104+
received: list[str] = []
105+
async with conn as events:
106+
async for event in events:
107+
received.append(event.data)
108+
if len(received) == 2:
109+
break
110+
111+
assert received == ["one", "two"]
112+
assert script.requests[1].headers.get("last-event-id") == "9"
113+
114+
115+
async def test_async_non_success_status_raises() -> None:
116+
script = _AsyncScript([_response(None, status=Status.BAD_GATEWAY)])
117+
conn = AsyncSseConnection(script, _request(), clock=_RecordingAsyncClock(), rand=_LowJitter())
118+
119+
with pytest.raises(HttpResponseError):
120+
async for _ in conn:
121+
pass
122+
assert len(script.requests) == 1
123+
124+
125+
async def test_async_cancellation_propagates_and_closes() -> None:
126+
body = _AsyncDropBody([b"data: hi\n\n"], block=True)
127+
script = _AsyncScript([_response(body)])
128+
conn = AsyncSseConnection(script, _request(), clock=_RecordingAsyncClock(), rand=_LowJitter())
129+
130+
seen: list[str] = []
131+
132+
async def consume() -> None:
133+
async with conn as events:
134+
async for event in events:
135+
seen.append(event.data)
136+
137+
task = asyncio.ensure_future(consume())
138+
for _ in range(10):
139+
await asyncio.sleep(0)
140+
if seen:
141+
break
142+
task.cancel()
143+
with pytest.raises(asyncio.CancelledError):
144+
await task
145+
146+
assert seen == ["hi"]
147+
assert body.closed is True

0 commit comments

Comments
 (0)