|
4 | 4 | import contextlib |
5 | 5 | import time |
6 | 6 | from logging import getLogger |
7 | | -from typing import TYPE_CHECKING, Annotated, Self |
| 7 | +from typing import TYPE_CHECKING, Annotated, Self, cast |
8 | 8 |
|
9 | 9 | import websockets.asyncio.client |
10 | 10 | import websockets.client |
|
20 | 20 | from apify.events._types import DeprecatedEvent, EventMessage, SystemInfoEventData, UnknownEvent |
21 | 21 |
|
22 | 22 | if TYPE_CHECKING: |
23 | | - from collections.abc import Generator |
| 23 | + from collections.abc import AsyncGenerator, Generator |
24 | 24 | from types import TracebackType |
25 | 25 |
|
26 | 26 | from crawlee.events._event_manager import EventManagerOptions |
@@ -149,31 +149,38 @@ async def _process_platform_messages(self, ws_url: str) -> None: |
149 | 149 |
|
150 | 150 | try: |
151 | 151 | # Used as an async iterator, `connect` reconnects with exponential backoff on failed connection attempts. |
152 | | - async for websocket in websockets.asyncio.client.connect( |
153 | | - ws_url, process_exception=self._process_connection_exception |
154 | | - ): |
155 | | - self._platform_events_websocket = websocket |
156 | | - if self._connected_to_platform_websocket and not self._connected_to_platform_websocket.done(): |
157 | | - self._connected_to_platform_websocket.set_result(True) |
158 | | - else: |
159 | | - logger.info('Reconnected to the platform events websocket.') |
160 | | - |
161 | | - connection_opened_at = time.monotonic() |
162 | | - connection_lost = await self._consume_messages(websocket) |
163 | | - |
164 | | - if not self._should_reconnect_after_close(websocket, connection_lost=connection_lost): |
165 | | - break |
166 | | - |
167 | | - # Reconnect a healthy connection immediately; back off only on repeated rapid drops. The first |
168 | | - # rapid drop reconnects once without delay (it only primes the backoff generator), and each |
169 | | - # subsequent consecutive rapid drop then sleeps for the next backoff interval. A healthy |
170 | | - # connection resets the generator, so the next rapid drop again gets that one free retry. |
171 | | - if time.monotonic() - connection_opened_at >= self._HEALTHY_CONNECTION_MIN_DURATION: |
172 | | - backoff_delays = None |
173 | | - elif backoff_delays is None: |
174 | | - backoff_delays = websockets.client.backoff() |
175 | | - else: |
176 | | - await asyncio.sleep(next(backoff_delays)) |
| 152 | + connector = websockets.asyncio.client.connect(ws_url, process_exception=self._process_connection_exception) |
| 153 | + |
| 154 | + # That iterator is an async generator whose cleanup closes the current connection, and neither `break` nor |
| 155 | + # cancelling this task closes it. Left suspended, it is closed by the garbage collector instead - on Actor |
| 156 | + # exit that lands in `asyncio.run` teardown, too late to be awaited, which then breaks the event loop's |
| 157 | + # async generator shutdown. `websockets` types the iterator as a plain `AsyncIterator`, hence the cast. |
| 158 | + connections = cast('AsyncGenerator[websockets.asyncio.client.ClientConnection]', aiter(connector)) |
| 159 | + |
| 160 | + async with contextlib.aclosing(connections): |
| 161 | + async for websocket in connections: |
| 162 | + self._platform_events_websocket = websocket |
| 163 | + if self._connected_to_platform_websocket and not self._connected_to_platform_websocket.done(): |
| 164 | + self._connected_to_platform_websocket.set_result(True) |
| 165 | + else: |
| 166 | + logger.info('Reconnected to the platform events websocket.') |
| 167 | + |
| 168 | + connection_opened_at = time.monotonic() |
| 169 | + connection_lost = await self._consume_messages(websocket) |
| 170 | + |
| 171 | + if not self._should_reconnect_after_close(websocket, connection_lost=connection_lost): |
| 172 | + break |
| 173 | + |
| 174 | + # Reconnect a healthy connection immediately; back off only on repeated rapid drops. The first |
| 175 | + # rapid drop reconnects once without delay (it only primes the backoff generator), and each |
| 176 | + # subsequent consecutive rapid drop then sleeps for the next backoff interval. A healthy |
| 177 | + # connection resets the generator, so the next rapid drop again gets that one free retry. |
| 178 | + if time.monotonic() - connection_opened_at >= self._HEALTHY_CONNECTION_MIN_DURATION: |
| 179 | + backoff_delays = None |
| 180 | + elif backoff_delays is None: |
| 181 | + backoff_delays = websockets.client.backoff() |
| 182 | + else: |
| 183 | + await asyncio.sleep(next(backoff_delays)) |
177 | 184 | except Exception: |
178 | 185 | logger.exception('Error in websocket connection') |
179 | 186 | if self._connected_to_platform_websocket is not None and not self._connected_to_platform_websocket.done(): |
|
0 commit comments