Skip to content

Commit e83cc8c

Browse files
committed
Merge fix/ws-reconnect into deephaven-fixes
2 parents 14564cb + 0ed8db6 commit e83cc8c

5 files changed

Lines changed: 737 additions & 38 deletions

File tree

‎docs/source/WebSocket.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ Close
4949
============================
5050
.. automethod:: massive.WebSocketClient.close
5151

52+
============================
53+
Request close
54+
============================
55+
.. automethod:: massive.WebSocketClient.request_close
56+
5257
.. _Stocks getting started: https://massive.com/docs/stocks/ws_getting-started
5358
.. _Options getting started: https://massive.com/docs/options/ws_getting-started
5459
.. _Forex getting started: https://massive.com/docs/forex/ws_getting-started

‎massive/websocket/__init__.py‎

Lines changed: 232 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,20 @@
1515
import logging
1616
import json
1717
import asyncio
18+
import random
1819
import ssl
1920
import sys
2021
import threading
2122
import time
2223
from .models import *
2324
from websockets.asyncio.client import connect, ClientConnection
24-
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
25+
from websockets.exceptions import (
26+
ConnectionClosed,
27+
ConnectionClosedOK,
28+
ConnectionClosedError,
29+
InvalidMessage,
30+
InvalidStatus,
31+
)
2532
from ..certs import ca_bundle_path
2633
from ..logging import get_logger
2734
import logging
@@ -44,10 +51,12 @@ def __init__(
4451
raw: bool = False,
4552
verbose: bool = False,
4653
subscriptions: Optional[List[str]] = None,
47-
max_reconnects: Optional[int] = 5,
54+
max_reconnects: Optional[int] = None,
4855
secure: bool = True,
4956
custom_json: Optional[Any] = None,
5057
ca_certs: Optional[str] = None,
58+
reconnect_backoff: float = 15.0,
59+
max_reconnect_backoff: float = 120.0,
5160
**kwargs,
5261
):
5362
"""
@@ -58,11 +67,19 @@ def __init__(
5867
:param raw: Whether to pass raw Union[str, bytes] to user callback.
5968
:param verbose: Whether to log client and server status messages.
6069
:param subscriptions: List of subscription parameters.
61-
:param max_reconnects: How many times to reconnect on network outage before ending .connect event loop.
70+
:param max_reconnects: How many consecutive reconnect attempts may fail (the connection is refused, or ends before or after authenticating) before .connect raises the last error. The count starts over whenever a connection authenticates. None, the default, retries forever. Only transient failures are retried and the wait between attempts grows, while other errors (e.g. HTTP 4xx) are raised at once, so with the default .run is safe in a long-lived process.
6271
:param custom_json: Optional module exposing loads/dumps functions (similar to Python's json module) to be used for JSON conversions.
6372
:param ca_certs: Path to a CA bundle to verify TLS with. Defaults to the SSL_CERT_FILE or REQUESTS_CA_BUNDLE environment variable, then certifi.
73+
:param reconnect_backoff: Seconds to wait before reconnecting after the connection ends or an attempt to connect fails. The wait doubles with each consecutive failure and gets up to 20% random jitter, but never leaves [reconnect_backoff, max_reconnect_backoff]. Defaults to 15, the minimum Massive asks clients to wait; 0 disables the wait and is meant only for tests.
74+
:param max_reconnect_backoff: The longest wait between reconnect attempts, jitter included.
75+
:raises ValueError: If reconnect_backoff is negative or greater than max_reconnect_backoff.
6476
:return: A client.
6577
"""
78+
if reconnect_backoff < 0 or max_reconnect_backoff < reconnect_backoff:
79+
raise ValueError(
80+
"need 0 <= reconnect_backoff <= max_reconnect_backoff, got "
81+
f"{reconnect_backoff} and {max_reconnect_backoff}"
82+
)
6683
if api_key is None:
6784
raise AuthError(
6885
f"Must specify env var {env_key} or pass api_key in constructor"
@@ -97,7 +114,7 @@ def __init__(
97114
# topics to subscribe to again even if already subscribed
98115
self._resend: Set[str] = set()
99116
# wake-up of connect() when subscriptions change: the loop it runs
100-
# on, whether a wake-up is queued there, whether one arrived since the
117+
# on (set only while connect() runs), whether a wake-up is queued there, whether one arrived since the
101118
# last reconciliation, and how to interrupt the recv() in progress
102119
self._loop: Optional[asyncio.AbstractEventLoop] = None
103120
self._wake_pending = False
@@ -113,6 +130,14 @@ def __init__(
113130
self.json = custom_json
114131
else:
115132
self.json = json
133+
# close() and request_close() stop connect()
134+
self._close_requested = threading.Event()
135+
self._close_event: Optional[asyncio.Event] = None
136+
self._close_task: Optional[asyncio.Future] = None
137+
# whether the server said max_connections during the last handshake
138+
self.max_connections_reached = False
139+
self.reconnect_backoff = reconnect_backoff
140+
self.max_reconnect_backoff = max_reconnect_backoff
116141

117142
# https://websockets.readthedocs.io/en/stable/reference/client.html#opening-a-connection
118143
async def connect(
@@ -127,31 +152,88 @@ async def connect(
127152
"""
128153
Connect to websocket server and run `processor(msg)` on every new `msg`.
129154
155+
When the connection ends, whether the server closed it cleanly or not,
156+
or an attempt to connect fails for a reason that may pass (the server
157+
is unreachable, times out, or answers with HTTP 5xx or 429), the
158+
client waits (see `reconnect_backoff`), reconnects, and subscribes
159+
again to every scheduled subscription. Other errors, e.g. HTTP 4xx or
160+
a TLS certificate that cannot be verified, are raised at once. It
161+
returns once `close()` or `request_close()` is called.
162+
163+
If the server sends a `max_connections` status during the handshake
164+
(the account has too many connections open), `max_connections_reached`
165+
is set until a later handshake succeeds, and in raw mode the message is
166+
also passed to `processor`. The server then closes the connection, and
167+
the client reconnects after the backoff.
168+
130169
:param processor: The callback to process messages.
131170
:param close_timeout: How long to wait for handshake when calling .close.
132171
:raises AuthError: If invalid API key is supplied.
172+
:raises Exception: The last error, if more than `max_reconnects`
173+
consecutive attempts fail; ConnectionClosedError if the server
174+
closed the last connection, even cleanly.
133175
"""
134-
reconnects = 0
135-
logger.debug("connect: %s", self.url)
176+
# reset the wake-up state before publishing the loop to other threads
136177
self._woken = False
137178
with self._subs_lock:
138179
self._wake_pending = False
139180
self._loop = asyncio.get_running_loop()
181+
self._close_event = asyncio.Event()
182+
try:
183+
await self._connect(processor, close_timeout, **kwargs)
184+
# it returned because close was requested, which is now done
185+
self._close_requested.clear()
186+
finally:
187+
self._loop = None
188+
self._close_event = None
189+
closing, self._close_task = self._close_task, None
190+
if closing is not None:
191+
await asyncio.gather(closing, return_exceptions=True)
192+
193+
async def _connect(
194+
self,
195+
processor: Union[
196+
Callable[[List[WebSocketMessage]], Awaitable],
197+
Callable[[Union[str, bytes]], Awaitable],
198+
],
199+
close_timeout: int,
200+
**kwargs,
201+
):
202+
# consecutive failed attempts, which set the backoff and are limited
203+
# by max_reconnects
204+
failures = 0
205+
logger.debug("connect: %s", self.url)
140206
ssl_context = self._ssl_context()
141207

142-
last_exc = None
143-
async for s in connect(
144-
self.url,
145-
close_timeout=close_timeout,
146-
ssl=ssl_context,
147-
process_exception=lambda exc: None,
148-
**kwargs,
149-
):
208+
# invalid arguments raise here; each attempt awaits it again
209+
connector = connect(
210+
self.url, close_timeout=close_timeout, ssl=ssl_context, **kwargs
211+
)
212+
last_exc: Optional[Exception] = None
213+
delay = 0.0
214+
while not self._close_requested.is_set():
215+
if failures > 0 and await self._wait_for_close(delay):
216+
return
217+
try:
218+
s = await self._open(connector)
219+
except Exception as e:
220+
if not self._is_transient(e):
221+
raise
222+
last_exc = e
223+
failures += 1
224+
if self._too_many_failures(failures):
225+
break
226+
delay = self._reconnect_delay(failures - 1)
227+
logger.warning("connect failed; reconnecting in %.1f s: %s", delay, e)
228+
continue
229+
if s is None:
230+
return
150231
self.websocket = s
151232
try:
152233
msg = await s.recv()
153234
logger.debug("connected: %s", msg)
154-
self._log_server_statuses(self.json.loads(msg))
235+
msg_parsed = self.json.loads(msg)
236+
self._log_server_statuses(msg_parsed)
155237
logger.debug("authing...")
156238
await s.send(
157239
self.json.dumps({"action": "auth", "params": self.api_key})
@@ -161,6 +243,14 @@ async def connect(
161243
logger.debug("authed: %s", auth_msg)
162244
if auth_msg_parsed[0]["status"] == "auth_failed":
163245
raise AuthError(auth_msg_parsed[0]["message"])
246+
self.max_connections_reached = False
247+
for frame, parsed in ((msg, msg_parsed), (auth_msg, auth_msg_parsed)):
248+
if any(m.get("status") == "max_connections" for m in parsed):
249+
self.max_connections_reached = True
250+
if self.raw:
251+
await processor(frame) # type: ignore
252+
if not self.max_connections_reached:
253+
failures = 0
164254
# e.g. max_connections, which the server sends before closing
165255
self._log_server_statuses(auth_msg_parsed)
166256
self._flush_server_errors(force=True)
@@ -186,31 +276,103 @@ async def connect(
186276

187277
if len(cmsg) > 0:
188278
await processor(cmsg) # type: ignore
189-
except ConnectionClosedOK as e:
190-
logger.debug("connection closed (OK): %s", e)
279+
except ConnectionClosed as e:
280+
ok = isinstance(e, ConnectionClosedOK)
281+
logger.debug("connection closed (%s): %s", "OK" if ok else "ERR", e)
191282
# the server sends a status (e.g. slow_consumer) before closing
192283
self._flush_server_errors(force=True)
193-
return
194-
except ConnectionClosedError as e:
284+
if self._close_requested.is_set():
285+
return
286+
# the server can close cleanly too, e.g. after max_connections
195287
last_exc = e
196-
logger.debug("connection closed (ERR): %s", e)
197-
self._flush_server_errors(force=True)
198-
reconnects += 1
288+
failures += 1
289+
if self._too_many_failures(failures):
290+
break
291+
delay = self._reconnect_delay(failures - 1)
292+
logger.warning(
293+
"connection closed; reconnecting in %.1f s: %s", delay, e
294+
)
295+
finally:
296+
self.websocket = None
297+
# scheduled_subs is the desired set, including changes not sent
298+
# yet: keep it, and subscribe all of it on the next connection
199299
with self._subs_lock:
200-
self.scheduled_subs = set(self.subs)
201300
self.subs = set()
202301
self.schedule_resub = True
203-
if self.max_reconnects is not None and reconnects > self.max_reconnects:
204-
break
205-
continue
206-
207-
if (
208-
last_exc
209-
and self.max_reconnects is not None
210-
and reconnects > self.max_reconnects
211-
):
302+
await s.close()
303+
304+
if last_exc is not None and self._too_many_failures(failures):
305+
if isinstance(last_exc, ConnectionClosedOK):
306+
# a clean close is still a failure once connect() gives up
307+
raise ConnectionClosedError(
308+
last_exc.rcvd, last_exc.sent, last_exc.rcvd_then_sent
309+
) from last_exc
212310
raise last_exc
213311

312+
def _too_many_failures(self, failures: int) -> bool:
313+
return self.max_reconnects is not None and failures > self.max_reconnects
314+
315+
@staticmethod
316+
def _is_transient(exc: Exception) -> bool:
317+
"""Whether a failed attempt to connect is worth retrying."""
318+
if isinstance(exc, InvalidStatus):
319+
status = exc.response.status_code
320+
return status >= 500 or status == 429
321+
if isinstance(exc, InvalidMessage):
322+
# e.g. the server closed the connection during the handshake
323+
return isinstance(exc.__cause__, EOFError)
324+
if isinstance(exc, ssl.SSLCertVerificationError):
325+
return False
326+
# e.g. refused, unreachable, or timed out
327+
return isinstance(exc, (OSError, asyncio.TimeoutError, ConnectionClosed))
328+
329+
def _reconnect_delay(self, attempt: int) -> float:
330+
"""
331+
The wait before reconnect attempt `attempt`, counting from 0.
332+
333+
The base doubles from `reconnect_backoff` up to `max_reconnect_backoff`;
334+
jitter only lengthens it, by up to 20%, and is clamped to the cap.
335+
"""
336+
# bound the exponent so a long outage cannot overflow the float
337+
base = min(
338+
self.reconnect_backoff * 2.0 ** min(attempt, 1000),
339+
self.max_reconnect_backoff,
340+
)
341+
return random.uniform(base, min(1.2 * base, self.max_reconnect_backoff))
342+
343+
async def _open(
344+
self, connector: Awaitable[ClientConnection]
345+
) -> Optional[ClientConnection]:
346+
"""Open a connection, or return None if close is requested first."""
347+
assert self._close_event is not None
348+
attempt = asyncio.ensure_future(connector)
349+
closing = asyncio.ensure_future(self._close_event.wait())
350+
try:
351+
await asyncio.wait({attempt, closing}, return_when=asyncio.FIRST_COMPLETED)
352+
except BaseException:
353+
attempt.cancel()
354+
closing.cancel()
355+
raise
356+
stop = closing.done() or self._close_requested.is_set()
357+
closing.cancel()
358+
if not stop:
359+
return attempt.result()
360+
# close was requested: abandon the attempt, or close what it opened
361+
attempt.cancel()
362+
await asyncio.wait({attempt})
363+
if not attempt.cancelled() and attempt.exception() is None:
364+
await attempt.result().close()
365+
return None
366+
367+
async def _wait_for_close(self, timeout: float) -> bool:
368+
"""Wait up to `timeout` seconds; return whether close was requested."""
369+
assert self._close_event is not None
370+
try:
371+
await asyncio.wait_for(self._close_event.wait(), timeout)
372+
except asyncio.TimeoutError:
373+
pass
374+
return self._close_requested.is_set()
375+
214376
def _ssl_context(self) -> Optional[ssl.SSLContext]:
215377
# darwin needs some extra <3
216378
if not self.url.startswith("wss://"):
@@ -516,15 +678,51 @@ def unsubscribe_all(self):
516678
self.schedule_resub = True
517679
self._notify()
518680

681+
def request_close(self):
682+
"""
683+
Close the websocket connection and make `connect()` return.
684+
685+
Unlike `close()`, this can be called from any thread, and it does not
686+
wait: `connect()` returns soon after, including while it is
687+
connecting or waiting to reconnect. If no `connect()` is running, the
688+
next one returns without connecting; the request stays pending until a
689+
`connect()` returns because of it.
690+
"""
691+
logger.debug("close requested")
692+
self._close_requested.set()
693+
loop = self._loop
694+
if loop is None:
695+
return
696+
try:
697+
loop.call_soon_threadsafe(self._on_close_requested)
698+
except RuntimeError:
699+
# the loop is closed, so connect() has returned
700+
pass
701+
702+
def _on_close_requested(self):
703+
# runs on the event loop of connect(); the request may be stale, e.g.
704+
# queued for an earlier connect() on the same loop
705+
if not self._close_requested.is_set():
706+
return
707+
if self._close_event is not None:
708+
self._close_event.set()
709+
if self.websocket is not None:
710+
self._close_task = asyncio.ensure_future(self.websocket.close())
711+
519712
async def close(self):
520713
"""
521-
Close the websocket connection.
714+
Close the websocket connection and stop `connect()` from reconnecting.
522715
"""
523716
logger.debug("closing")
717+
if self._loop is not None:
718+
self._close_requested.set()
719+
if self._close_event is not None:
720+
self._close_event.set()
524721
self._flush_server_errors(force=True)
525722

526723
if self.websocket:
527724
await self.websocket.close()
528725
self.websocket = None
529-
else:
726+
elif self._loop is None:
727+
# (while connect() runs, it may be between connections)
530728
logger.warning("no websocket open to close")

‎test_websocket/test_fake_server.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ async def test_auth_failed_raises(self) -> None:
6666
self.assertEqual(self.server.inbox, [{"action": "auth", "params": "bad"}])
6767

6868
async def test_resubscribes_after_reconnect(self) -> None:
69-
c = self.client(subscriptions=["T.AAPL", "Q.AAPL"])
69+
c = self.client(subscriptions=["T.AAPL", "Q.AAPL"], reconnect_backoff=0.01)
7070
task = asyncio.create_task(c.connect(lambda msgs: asyncio.sleep(0)))
7171
await self.server.wait_for_subscriptions({"T.AAPL", "Q.AAPL"})
7272

0 commit comments

Comments
 (0)