Skip to content

Commit 358c636

Browse files
committed
feat(streaming): add resumable session event streams
1 parent bb7ebcb commit 358c636

8 files changed

Lines changed: 1009 additions & 14 deletions

File tree

README.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,21 +158,29 @@ Reuse one `session_id` for the whole conversation, and reuse one idempotency key
158158

159159
## Resuming a stream
160160

161-
The SDK does not reconnect a dropped stream on its own. Persist `stream.last_event_id` and pass it back as `last_event_id` on the next call; the server replays from there, so you never have to resend a message it already accepted.
161+
The low-level `stream()` method exposes `stream.last_event_id` but does not reconnect. Use the handwritten `resumable_stream()` helper to reconnect automatically after EOF, timeouts, transient transport failures, and retryable HTTP statuses. It checkpoints only complete events and sends the latest checkpoint as `Last-Event-ID`; close it with a context manager when finished.
162162

163163
```python
164-
from qca import APIConnectionError
164+
with client.sessions.events.resumable_stream(
165+
session_id,
166+
last_event_id=saved_event_id,
167+
event_deltas=["agent.message"],
168+
) as stream:
169+
for event in stream:
170+
saved_event_id = stream.last_event_id
171+
print(event.type)
172+
```
165173

166-
last_event_id = None
167-
try:
168-
with client.sessions.events.stream(session_id, last_event_id=last_event_id) as stream:
169-
for event in stream:
170-
last_event_id = stream.last_event_id
171-
print(event.type)
172-
except APIConnectionError:
173-
pass # reconnect with the last_event_id recorded above
174+
The async form is a native async iterator and context manager; unlike `stream()`, constructing it does not require `await`:
175+
176+
```python
177+
async with client.sessions.events.resumable_stream(session_id) as stream:
178+
async for event in stream:
179+
print(event.type)
174180
```
175181

182+
The helper retries until closed, cancelled, or it receives `session.status_terminated` / `session.deleted`. It uses jittered exponential backoff and the same HTTP retry classification as the client (in particular, `409` is not retried). It does not query event history, discard an invalid cursor, or deduplicate event IDs because multiple preview deltas may share one ID.
183+
176184
Events are also readable after the fact through `client.sessions.events.list(session_id)`, which paginates like any other list method.
177185

178186
## Handling errors
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
from __future__ import annotations
2+
3+
import random
4+
import threading
5+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
6+
from time import monotonic
7+
from typing import Generic, TypeVar
8+
9+
import anyio
10+
import httpx
11+
12+
from ._exceptions import APIConnectionError, APIStatusError
13+
from ._streaming import AsyncStream, Stream
14+
15+
T = TypeVar("T")
16+
17+
_INITIAL_BACKOFF = 0.5
18+
_MAX_BACKOFF = 10.0
19+
_HEALTHY_RESET_AFTER = 5.0
20+
_TERMINAL_EVENT_TYPES = frozenset({"session.status_terminated", "session.deleted"})
21+
22+
Retryable = Callable[[httpx.Request, httpx.Response | None], bool]
23+
24+
25+
def _is_terminal_event(event: object) -> bool:
26+
return getattr(event, "type", None) in _TERMINAL_EVENT_TYPES
27+
28+
29+
class ResumableStream(Generic[T], Iterator[T]):
30+
def __init__(
31+
self,
32+
*,
33+
open_stream: Callable[[str | None], Stream[T]],
34+
retryable: Retryable,
35+
last_event_id: str | None,
36+
) -> None:
37+
self._open_stream = open_stream
38+
self._retryable = retryable
39+
self._last_event_id = last_event_id
40+
self._backoff = _INITIAL_BACKOFF
41+
self._closed = threading.Event()
42+
self._stream: Stream[T] | None = None
43+
self._iterator = self._iter_events()
44+
45+
@property
46+
def last_event_id(self) -> str | None:
47+
return self._last_event_id
48+
49+
def __next__(self) -> T:
50+
return next(self._iterator)
51+
52+
def _iter_events(self) -> Iterator[T]:
53+
while not self._closed.is_set():
54+
connected_at: float | None = None
55+
connected_for: float | None = None
56+
stream: Stream[T] | None = None
57+
try:
58+
stream = self._open_stream(self._last_event_id)
59+
self._stream = stream
60+
connected_at = monotonic()
61+
if self._closed.is_set():
62+
return
63+
for event in stream:
64+
if self._closed.is_set():
65+
return
66+
if stream.last_event_id:
67+
self._last_event_id = stream.last_event_id
68+
terminal = _is_terminal_event(event)
69+
yield event
70+
if terminal:
71+
return
72+
except APIConnectionError as exc:
73+
if self._closed.is_set():
74+
return
75+
if not self._retryable(exc.request, None):
76+
raise
77+
except APIStatusError as exc:
78+
if self._closed.is_set():
79+
return
80+
if not self._retryable(exc.request, exc.response):
81+
raise
82+
finally:
83+
if connected_at is not None:
84+
connected_for = monotonic() - connected_at
85+
if stream is not None:
86+
stream.close()
87+
self._stream = None
88+
89+
if self._closed.is_set():
90+
return
91+
if connected_for is not None and connected_for >= _HEALTHY_RESET_AFTER:
92+
self._backoff = _INITIAL_BACKOFF
93+
delay = random.uniform(self._backoff / 2, self._backoff)
94+
self._backoff = min(self._backoff * 2, _MAX_BACKOFF)
95+
if self._closed.wait(delay):
96+
return
97+
98+
def close(self) -> None:
99+
self._closed.set()
100+
if self._stream is not None:
101+
self._stream.close()
102+
103+
def __enter__(self) -> ResumableStream[T]:
104+
return self
105+
106+
def __exit__(self, *_: object) -> None:
107+
self.close()
108+
109+
110+
class AsyncResumableStream(Generic[T], AsyncIterator[T]):
111+
def __init__(
112+
self,
113+
*,
114+
open_stream: Callable[[str | None], Awaitable[AsyncStream[T]]],
115+
retryable: Retryable,
116+
last_event_id: str | None,
117+
) -> None:
118+
self._open_stream = open_stream
119+
self._retryable = retryable
120+
self._last_event_id = last_event_id
121+
self._backoff = _INITIAL_BACKOFF
122+
self._closed = False
123+
self._close_event: anyio.Event | None = None
124+
self._cancel_scope: anyio.CancelScope | None = None
125+
self._stream: AsyncStream[T] | None = None
126+
self._iterator = self._iter_events()
127+
128+
@property
129+
def last_event_id(self) -> str | None:
130+
return self._last_event_id
131+
132+
async def __anext__(self) -> T:
133+
return await self._iterator.__anext__()
134+
135+
async def _open_next_stream(self) -> AsyncStream[T] | None:
136+
stream: AsyncStream[T] | None = None
137+
with anyio.CancelScope() as cancel_scope:
138+
self._cancel_scope = cancel_scope
139+
if self._closed:
140+
cancel_scope.cancel()
141+
try:
142+
stream = await self._open_stream(self._last_event_id)
143+
finally:
144+
if self._cancel_scope is cancel_scope:
145+
self._cancel_scope = None
146+
return stream
147+
148+
async def _next_event(self, stream: AsyncStream[T]) -> tuple[bool, T | None]:
149+
event: T | None = None
150+
exhausted = False
151+
with anyio.CancelScope() as cancel_scope:
152+
self._cancel_scope = cancel_scope
153+
if self._closed:
154+
cancel_scope.cancel()
155+
try:
156+
event = await stream.__anext__()
157+
except StopAsyncIteration:
158+
exhausted = True
159+
finally:
160+
if self._cancel_scope is cancel_scope:
161+
self._cancel_scope = None
162+
return exhausted, event
163+
164+
async def _close_stream(self, stream: AsyncStream[T]) -> None:
165+
if self._stream is not stream:
166+
return
167+
self._stream = None
168+
await stream.close()
169+
170+
async def _iter_events(self) -> AsyncIterator[T]:
171+
while not self._closed:
172+
connected_at: float | None = None
173+
connected_for: float | None = None
174+
stream: AsyncStream[T] | None = None
175+
try:
176+
stream = await self._open_next_stream()
177+
if stream is None:
178+
return
179+
self._stream = stream
180+
connected_at = monotonic()
181+
if self._closed:
182+
return
183+
while not self._closed:
184+
exhausted, event = await self._next_event(stream)
185+
if self._closed:
186+
return
187+
if exhausted:
188+
break
189+
if event is None:
190+
raise RuntimeError("stream read was interrupted without closing")
191+
if stream.last_event_id:
192+
self._last_event_id = stream.last_event_id
193+
terminal = _is_terminal_event(event)
194+
yield event
195+
if terminal:
196+
return
197+
except APIConnectionError as exc:
198+
if self._closed:
199+
return
200+
if not self._retryable(exc.request, None):
201+
raise
202+
except APIStatusError as exc:
203+
if self._closed:
204+
return
205+
if not self._retryable(exc.request, exc.response):
206+
raise
207+
finally:
208+
if connected_at is not None:
209+
connected_for = monotonic() - connected_at
210+
if stream is not None:
211+
with anyio.CancelScope(shield=True):
212+
await self._close_stream(stream)
213+
214+
if self._closed:
215+
return
216+
if connected_for is not None and connected_for >= _HEALTHY_RESET_AFTER:
217+
self._backoff = _INITIAL_BACKOFF
218+
delay = random.uniform(self._backoff / 2, self._backoff)
219+
self._backoff = min(self._backoff * 2, _MAX_BACKOFF)
220+
close_event = anyio.Event()
221+
self._close_event = close_event
222+
if self._closed:
223+
close_event.set()
224+
with anyio.move_on_after(delay):
225+
await close_event.wait()
226+
self._close_event = None
227+
228+
async def close(self) -> None:
229+
if self._closed:
230+
return
231+
self._closed = True
232+
if self._close_event is not None:
233+
self._close_event.set()
234+
if self._cancel_scope is not None:
235+
self._cancel_scope.cancel()
236+
if self._stream is not None:
237+
with anyio.CancelScope(shield=True):
238+
await self._close_stream(self._stream)
239+
240+
async def __aenter__(self) -> AsyncResumableStream[T]:
241+
return self
242+
243+
async def __aexit__(self, *_: object) -> None:
244+
await self.close()

src/qca/forward/resources/sessions/events.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import httpx
66

77
from qca.common._resource import AsyncAPIResource, SyncAPIResource
8+
from qca.common._resumable_streaming import AsyncResumableStream, ResumableStream
89
from qca.common._streaming import AsyncStream, Stream
910
from qca.common._types import NOT_GIVEN, NotGiven
1011
from qca.common._utils import make_request_options, path_template
@@ -16,6 +17,17 @@
1617
__all__ = ["Events", "AsyncEvents"]
1718

1819

20+
def _resumable_options(
21+
last_event_id: Union[str, None, NotGiven], extra_headers: Dict[str, str] | None
22+
) -> tuple[str | None, Dict[str, str]]:
23+
cursor = None if isinstance(last_event_id, NotGiven) else last_event_id
24+
headers = dict(extra_headers or {})
25+
for name in tuple(headers):
26+
if name.lower() == "last-event-id":
27+
cursor = headers.pop(name)
28+
return cursor, headers
29+
30+
1931
class Events(SyncAPIResource):
2032
def list(
2133
self,
@@ -110,6 +122,40 @@ def stream(
110122
)
111123
return self._client.request("GET", _path, cast_to=SessionEvent, options=options, stream=True)
112124

125+
def resumable_stream(
126+
self,
127+
session_id: str,
128+
*,
129+
event_deltas: Union[List[str], None, NotGiven] = NOT_GIVEN,
130+
include_tool_calls: Union[bool, None, NotGiven] = NOT_GIVEN,
131+
include_thinking: Union[bool, None, NotGiven] = NOT_GIVEN,
132+
last_event_id: Union[str, None, NotGiven] = NOT_GIVEN,
133+
extra_headers: Dict[str, str] | None = None,
134+
extra_query: Dict[str, Any] | None = None,
135+
extra_body: Dict[str, Any] | None = None,
136+
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
137+
) -> ResumableStream[SessionEvent]:
138+
"""Continuously reconnect GET /sessions/{session_id}/events/stream."""
139+
cursor, headers = _resumable_options(last_event_id, extra_headers)
140+
deltas = list(event_deltas) if isinstance(event_deltas, list) else event_deltas
141+
query = dict(extra_query or {})
142+
body = dict(extra_body or {})
143+
return ResumableStream(
144+
open_stream=lambda next_cursor: self.stream(
145+
session_id,
146+
event_deltas=deltas,
147+
include_tool_calls=include_tool_calls,
148+
include_thinking=include_thinking,
149+
last_event_id=next_cursor,
150+
extra_headers=headers,
151+
extra_query=query,
152+
extra_body=body,
153+
timeout=timeout,
154+
),
155+
retryable=self._client._retryable,
156+
last_event_id=cursor,
157+
)
158+
113159

114160
class AsyncEvents(AsyncAPIResource):
115161
def list(
@@ -206,3 +252,37 @@ async def stream(
206252
timeout=timeout,
207253
)
208254
return await self._client.request("GET", _path, cast_to=SessionEvent, options=options, stream=True)
255+
256+
def resumable_stream(
257+
self,
258+
session_id: str,
259+
*,
260+
event_deltas: Union[List[str], None, NotGiven] = NOT_GIVEN,
261+
include_tool_calls: Union[bool, None, NotGiven] = NOT_GIVEN,
262+
include_thinking: Union[bool, None, NotGiven] = NOT_GIVEN,
263+
last_event_id: Union[str, None, NotGiven] = NOT_GIVEN,
264+
extra_headers: Dict[str, str] | None = None,
265+
extra_query: Dict[str, Any] | None = None,
266+
extra_body: Dict[str, Any] | None = None,
267+
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
268+
) -> AsyncResumableStream[SessionEvent]:
269+
"""Continuously reconnect GET /sessions/{session_id}/events/stream."""
270+
cursor, headers = _resumable_options(last_event_id, extra_headers)
271+
deltas = list(event_deltas) if isinstance(event_deltas, list) else event_deltas
272+
query = dict(extra_query or {})
273+
body = dict(extra_body or {})
274+
return AsyncResumableStream(
275+
open_stream=lambda next_cursor: self.stream(
276+
session_id,
277+
event_deltas=deltas,
278+
include_tool_calls=include_tool_calls,
279+
include_thinking=include_thinking,
280+
last_event_id=next_cursor,
281+
extra_headers=headers,
282+
extra_query=query,
283+
extra_body=body,
284+
timeout=timeout,
285+
),
286+
retryable=self._client._retryable,
287+
last_event_id=cursor,
288+
)

0 commit comments

Comments
 (0)