|
22 | 22 |
|
23 | 23 | from ...errors import HttpResponseError, ServiceRequestError, ServiceResponseError |
24 | 24 | from ...pipeline.dispatch import ( |
| 25 | + AsyncPipelineLike, |
| 26 | + SendAsync, |
25 | 27 | SendSync, |
26 | 28 | SyncPipelineLike, |
27 | 29 | ) |
28 | | -from ...util.clock import SYSTEM_CLOCK, Clock |
| 30 | +from ...util.clock import ASYNC_SYSTEM_CLOCK, SYSTEM_CLOCK, AsyncClock, Clock |
29 | 31 | 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 |
31 | 34 |
|
32 | 35 | if TYPE_CHECKING: |
33 | | - from collections.abc import Callable, Generator, Iterator |
| 36 | + from collections.abc import AsyncIterator, Callable, Generator, Iterator |
34 | 37 | from types import TracebackType |
35 | 38 | from typing import Self |
36 | 39 |
|
37 | 40 | from ..request.request import Request |
| 41 | + from ..response.async_response import AsyncResponse |
38 | 42 | from ..response.response import Response |
39 | 43 | from .parser import SseEvent |
40 | 44 |
|
@@ -245,4 +249,145 @@ def _stream(self, response: Response) -> Generator[SseEvent, None, bool]: |
245 | 249 | return progressed |
246 | 250 |
|
247 | 251 |
|
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"] |
0 commit comments