1515import logging
1616import json
1717import asyncio
18+ import random
1819import ssl
1920import sys
2021import threading
2122import time
2223from .models import *
2324from 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+ )
2532from ..certs import ca_bundle_path
2633from ..logging import get_logger
2734import 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" )
0 commit comments