Skip to content

Commit 844d588

Browse files
tmunzer-AIDEclaude
andcommitted
fix(websockets): address review findings on reliability rework
- derive ping_timeout as min(45, ping_interval - 1) when not supplied, so existing callers passing only ping_interval keep working - drain the callback queue on shutdown instead of dropping pending messages; the None sentinel now marks end-of-stream - report subscription watchdog timeouts and subscribe_failed events through on_error so callers without auto_reconnect get a signal - don't resurrect the callback worker after disconnect (stop flag is now only cleared by connect()) - cite the Mist rate-limits doc for the channel-count constants - document the new reliability kwargs in all channel class docstrings - add tests: ping derivation, worker drain, worker stop guard, watchdog expiry/cancel, subscribe_failed error reporting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 527841c commit 844d588

7 files changed

Lines changed: 415 additions & 71 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,12 +584,12 @@ All channel classes accept the following optional keyword arguments:
584584
| Parameter | Type | Default | Description |
585585
|-----------|------|---------|-------------|
586586
| `ping_interval` | `int` | `60` | Seconds between automatic ping frames. Set to `0` to disable pings. |
587-
| `ping_timeout` | `int` | `45` | Seconds to wait for a pong response before treating the connection as dead. When `ping_interval > 0`, this must be lower than `ping_interval`. |
587+
| `ping_timeout` | `int \| None` | `None` | Seconds to wait for a pong response before treating the connection as dead. Defaults to `min(45, ping_interval - 1)`. When `ping_interval > 0`, this must be lower than `ping_interval`. |
588588
| `auto_reconnect` | `bool` | `False` | Automatically reconnect on transient failures using exponential backoff. |
589589
| `max_reconnect_attempts` | `int` | `5` | Maximum number of reconnect attempts before giving up. |
590590
| `reconnect_backoff` | `float` | `2.0` | Base backoff delay in seconds. Doubles after each failed attempt (2s, 4s, 8s, ...). Resets on successful reconnection. |
591591
| `queue_maxsize` | `int` | `0` | Maximum messages buffered in the internal queues used for both `receive()` and callback delivery. `0` means unbounded. When set, incoming messages are dropped with a warning when either queue is full, preventing memory growth on high-frequency streams. |
592-
| `subscription_watchdog_timeout` | `float` | `10.0` | Maximum time to wait for all `channel_subscribed` acknowledgements after connect. On timeout, the connection is closed to trigger a clean reconnect. |
592+
| `subscription_watchdog_timeout` | `float` | `10.0` | Maximum time to wait for all `channel_subscribed` acknowledgements after connect. On timeout, the error is reported to `on_error` and the connection is closed; with `auto_reconnect=True` this triggers a clean reconnect. |
593593
| `rate_limit_backoff` | `float` | `30.0` | Minimum reconnect delay after a 429 rate-limit response. |
594594
| `throughput_log_interval` | `int` | `100` | Logs queue depth and processed counts every N messages. Set to `0` to disable periodic throughput logs. |
595595

src/mistapi/websockets/__ws_client.py

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,15 @@ def filter(self, record: logging.LogRecord) -> bool:
4848
from mistapi import APISession
4949

5050

51+
# Channel limits from the Mist WebSocket documentation:
52+
# https://www.juniper.net/documentation/us/en/software/mist/api/http/guides/websockets/rate-limits
5153
MAX_CHANNELS_PER_CONNECTION = 2000
5254
HIGH_CHANNEL_COUNT_WARNING = 1500
5355

56+
# Default pong timeout, and the value ping_timeout is derived from when only
57+
# ping_interval is supplied (min(DEFAULT_PING_TIMEOUT, ping_interval - 1)).
58+
DEFAULT_PING_TIMEOUT = 45
59+
5460

5561
class _MistWebsocket:
5662
"""
@@ -69,7 +75,7 @@ def __init__(
6975
mist_session: "APISession",
7076
channels: list[str],
7177
ping_interval: int = 60,
72-
ping_timeout: int = 45,
78+
ping_timeout: int | None = None,
7379
auto_reconnect: bool = False,
7480
max_reconnect_attempts: int = 5,
7581
reconnect_backoff: float = 2.0,
@@ -81,6 +87,20 @@ def __init__(
8187
) -> None:
8288
if ping_interval < 0:
8389
raise ValueError("ping_interval must be >= 0")
90+
if ping_timeout is None:
91+
# Derive a valid pong timeout so callers who only set
92+
# ping_interval keep working (websocket-client requires
93+
# ping_timeout < ping_interval).
94+
ping_timeout = (
95+
min(DEFAULT_PING_TIMEOUT, ping_interval - 1)
96+
if ping_interval > 0
97+
else DEFAULT_PING_TIMEOUT
98+
)
99+
if ping_timeout < 1:
100+
raise ValueError(
101+
"ping_interval must be >= 2 to leave room for the pong "
102+
"timeout, or set ping_interval=0 to disable pings"
103+
)
84104
if ping_timeout <= 0:
85105
raise ValueError("ping_timeout must be > 0")
86106
if ping_interval and ping_interval <= ping_timeout:
@@ -274,28 +294,31 @@ def _drain_queue(self, target_queue: queue.Queue[Any]) -> None:
274294
break
275295

276296
def _start_callback_worker(self) -> None:
297+
if self._callback_stop.is_set():
298+
# Disconnecting/stopped: don't resurrect the worker for a late
299+
# message. connect() clears the flag before starting a new run.
300+
return
277301
if self._callback_thread is not None and self._callback_thread.is_alive():
278302
return
279-
self._callback_stop.clear()
280303
self._callback_thread = threading.Thread(
281304
target=self._run_callback_worker, daemon=True
282305
)
283306
self._callback_thread.start()
284307

285308
def _run_callback_worker(self) -> None:
309+
# On stop, keep draining so every message received before the stop
310+
# (i.e. before the None sentinel) is still delivered to the callback.
286311
while True:
287-
if self._callback_stop.is_set():
288-
break
289312
try:
290313
item = self._callback_queue.get(timeout=1)
291314
except queue.Empty:
315+
if self._callback_stop.is_set():
316+
break # stop requested and queue fully drained
292317
if self._finished.is_set() and self._callback_queue.empty():
293318
break
294319
continue
295320
if item is None:
296-
if self._callback_stop.is_set() or self._finished.is_set():
297-
break
298-
continue
321+
break # end-of-stream sentinel; everything before it was delivered
299322
callback = self._on_message_cb
300323
if callback is None:
301324
continue
@@ -348,13 +371,18 @@ def _watchdog_expired() -> None:
348371
self._last_close_msg = (
349372
f"subscription watchdog timeout: missing {len(missing)} channels"
350373
)
351-
logger.error(
352-
"Subscription watchdog timeout after %.1fs: received %d/%d subscriptions. "
353-
"Missing: %s",
354-
self._subscription_watchdog_timeout,
355-
len(self._expected_channels) - len(missing),
356-
len(self._expected_channels),
357-
preview,
374+
# Surface through on_error so callers without auto_reconnect
375+
# still get an actionable signal (the close alone reconnects
376+
# only when auto_reconnect is enabled).
377+
self._handle_error(
378+
ws,
379+
TimeoutError(
380+
f"subscription watchdog timeout after "
381+
f"{self._subscription_watchdog_timeout:.1f}s: received "
382+
f"{len(self._expected_channels) - len(missing)}/"
383+
f"{len(self._expected_channels)} subscriptions. "
384+
f"Missing: {preview}"
385+
),
358386
)
359387
ws.close()
360388

@@ -405,14 +433,15 @@ def _process_subscription_event(
405433

406434
if event == "subscribe_failed":
407435
detail = data.get("detail")
408-
logger.error(
409-
"Subscription failed for channel %s: %s. Closing to trigger reconnect.",
410-
channel,
411-
detail,
412-
)
413436
self._last_close_code = 1008
414437
self._last_close_msg = f"subscribe_failed channel={channel} detail={detail}"
415438
self._cancel_subscription_watchdog()
439+
# Surface through on_error so callers without auto_reconnect
440+
# still get an actionable signal before the connection closes.
441+
self._handle_error(
442+
ws,
443+
ConnectionError(f"subscription failed for channel {channel}: {detail}"),
444+
)
416445
ws.close()
417446

418447
def _enqueue_message(self, message: dict, to_callback_queue: bool) -> None:
@@ -482,8 +511,7 @@ def _handle_message(self, ws: websocket.WebSocketApp, message: str | bytes) -> N
482511
if not isinstance(data, dict):
483512
data = {"data": data}
484513

485-
if isinstance(data, dict):
486-
self._process_subscription_event(ws, data)
514+
self._process_subscription_event(ws, data)
487515

488516
if self._on_message_cb:
489517
self._start_callback_worker()

src/mistapi/websockets/location.py

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ class BleAssetsEvents(_MistWebsocket):
3131
UUIDs of the maps to stream events from.
3232
ping_interval : int, default 60
3333
Interval in seconds to send WebSocket ping frames (keep-alive).
34-
ping_timeout : int, default 45
35-
Time in seconds to wait for a ping response before considering the connection dead.
34+
ping_timeout : int | None, default None
35+
Time in seconds to wait for a ping response before considering the
36+
connection dead. Defaults to ``min(45, ping_interval - 1)``. Must be
37+
lower than ping_interval.
3638
auto_reconnect : bool, default False
3739
Automatically reconnect on unexpected disconnections using exponential backoff.
3840
max_reconnect_attempts : int, default 5
@@ -46,6 +48,17 @@ class BleAssetsEvents(_MistWebsocket):
4648
``receive()`` generator. ``0`` means unbounded. When set,
4749
incoming messages are dropped with a warning when the queue is
4850
full, preventing memory growth on high-frequency streams.
51+
subscription_watchdog_timeout : float, default 10.0
52+
Maximum time in seconds to wait for all channel subscription
53+
acknowledgements after connect. On timeout, the error is reported to
54+
``on_error`` and the connection is closed (auto_reconnect, when
55+
enabled, then reconnects).
56+
rate_limit_backoff : float, default 30.0
57+
Minimum reconnect delay in seconds after an HTTP 429 rate-limit
58+
response.
59+
throughput_log_interval : int, default 100
60+
Log queue depth and processed counts every N messages. ``0`` disables
61+
periodic throughput logs.
4962
5063
EXAMPLE
5164
-----------
@@ -78,7 +91,7 @@ def __init__(
7891
site_id: str,
7992
map_ids: list[str],
8093
ping_interval: int = 60,
81-
ping_timeout: int = 45,
94+
ping_timeout: int | None = None,
8295
auto_reconnect: bool = False,
8396
max_reconnect_attempts: int = 5,
8497
reconnect_backoff: float = 2.0,
@@ -121,8 +134,10 @@ class ConnectedClientsEvents(_MistWebsocket):
121134
UUIDs of the maps to stream events from.
122135
ping_interval : int, default 60
123136
Interval in seconds to send WebSocket ping frames (keep-alive).
124-
ping_timeout : int, default 45
125-
Time in seconds to wait for a ping response before considering the connection dead.
137+
ping_timeout : int | None, default None
138+
Time in seconds to wait for a ping response before considering the
139+
connection dead. Defaults to ``min(45, ping_interval - 1)``. Must be
140+
lower than ping_interval.
126141
auto_reconnect : bool, default False
127142
Automatically reconnect on unexpected disconnections using exponential backoff.
128143
max_reconnect_attempts : int, default 5
@@ -136,6 +151,17 @@ class ConnectedClientsEvents(_MistWebsocket):
136151
``receive()`` generator. ``0`` means unbounded. When set,
137152
incoming messages are dropped with a warning when the queue is
138153
full, preventing memory growth on high-frequency streams.
154+
subscription_watchdog_timeout : float, default 10.0
155+
Maximum time in seconds to wait for all channel subscription
156+
acknowledgements after connect. On timeout, the error is reported to
157+
``on_error`` and the connection is closed (auto_reconnect, when
158+
enabled, then reconnects).
159+
rate_limit_backoff : float, default 30.0
160+
Minimum reconnect delay in seconds after an HTTP 429 rate-limit
161+
response.
162+
throughput_log_interval : int, default 100
163+
Log queue depth and processed counts every N messages. ``0`` disables
164+
periodic throughput logs.
139165
140166
EXAMPLE
141167
-----------
@@ -168,7 +194,7 @@ def __init__(
168194
site_id: str,
169195
map_ids: list[str],
170196
ping_interval: int = 60,
171-
ping_timeout: int = 45,
197+
ping_timeout: int | None = None,
172198
auto_reconnect: bool = False,
173199
max_reconnect_attempts: int = 5,
174200
reconnect_backoff: float = 2.0,
@@ -211,8 +237,10 @@ class SdkClientsEvents(_MistWebsocket):
211237
UUIDs of the maps to stream events from.
212238
ping_interval : int, default 60
213239
Interval in seconds to send WebSocket ping frames (keep-alive).
214-
ping_timeout : int, default 45
215-
Time in seconds to wait for a ping response before considering the connection dead.
240+
ping_timeout : int | None, default None
241+
Time in seconds to wait for a ping response before considering the
242+
connection dead. Defaults to ``min(45, ping_interval - 1)``. Must be
243+
lower than ping_interval.
216244
auto_reconnect : bool, default False
217245
Automatically reconnect on unexpected disconnections using exponential backoff.
218246
max_reconnect_attempts : int, default 5
@@ -226,6 +254,17 @@ class SdkClientsEvents(_MistWebsocket):
226254
``receive()`` generator. ``0`` means unbounded. When set,
227255
incoming messages are dropped with a warning when the queue is
228256
full, preventing memory growth on high-frequency streams.
257+
subscription_watchdog_timeout : float, default 10.0
258+
Maximum time in seconds to wait for all channel subscription
259+
acknowledgements after connect. On timeout, the error is reported to
260+
``on_error`` and the connection is closed (auto_reconnect, when
261+
enabled, then reconnects).
262+
rate_limit_backoff : float, default 30.0
263+
Minimum reconnect delay in seconds after an HTTP 429 rate-limit
264+
response.
265+
throughput_log_interval : int, default 100
266+
Log queue depth and processed counts every N messages. ``0`` disables
267+
periodic throughput logs.
229268
230269
EXAMPLE
231270
-----------
@@ -258,7 +297,7 @@ def __init__(
258297
site_id: str,
259298
map_ids: list[str],
260299
ping_interval: int = 60,
261-
ping_timeout: int = 45,
300+
ping_timeout: int | None = None,
262301
auto_reconnect: bool = False,
263302
max_reconnect_attempts: int = 5,
264303
reconnect_backoff: float = 2.0,
@@ -301,8 +340,10 @@ class UnconnectedClientsEvents(_MistWebsocket):
301340
UUIDs of the maps to stream events from.
302341
ping_interval : int, default 60
303342
Interval in seconds to send WebSocket ping frames (keep-alive).
304-
ping_timeout : int, default 45
305-
Time in seconds to wait for a ping response before considering the connection dead.
343+
ping_timeout : int | None, default None
344+
Time in seconds to wait for a ping response before considering the
345+
connection dead. Defaults to ``min(45, ping_interval - 1)``. Must be
346+
lower than ping_interval.
306347
auto_reconnect : bool, default False
307348
Automatically reconnect on unexpected disconnections using exponential backoff.
308349
max_reconnect_attempts : int, default 5
@@ -316,6 +357,17 @@ class UnconnectedClientsEvents(_MistWebsocket):
316357
``receive()`` generator. ``0`` means unbounded. When set,
317358
incoming messages are dropped with a warning when the queue is
318359
full, preventing memory growth on high-frequency streams.
360+
subscription_watchdog_timeout : float, default 10.0
361+
Maximum time in seconds to wait for all channel subscription
362+
acknowledgements after connect. On timeout, the error is reported to
363+
``on_error`` and the connection is closed (auto_reconnect, when
364+
enabled, then reconnects).
365+
rate_limit_backoff : float, default 30.0
366+
Minimum reconnect delay in seconds after an HTTP 429 rate-limit
367+
response.
368+
throughput_log_interval : int, default 100
369+
Log queue depth and processed counts every N messages. ``0`` disables
370+
periodic throughput logs.
319371
320372
EXAMPLE
321373
-----------
@@ -348,7 +400,7 @@ def __init__(
348400
site_id: str,
349401
map_ids: list[str],
350402
ping_interval: int = 60,
351-
ping_timeout: int = 45,
403+
ping_timeout: int | None = None,
352404
auto_reconnect: bool = False,
353405
max_reconnect_attempts: int = 5,
354406
reconnect_backoff: float = 2.0,
@@ -393,8 +445,10 @@ class DiscoveredBleAssetsEvents(_MistWebsocket):
393445
UUIDs of the maps to stream events from.
394446
ping_interval : int, default 60
395447
Interval in seconds to send WebSocket ping frames (keep-alive).
396-
ping_timeout : int, default 45
397-
Time in seconds to wait for a ping response before considering the connection dead.
448+
ping_timeout : int | None, default None
449+
Time in seconds to wait for a ping response before considering the
450+
connection dead. Defaults to ``min(45, ping_interval - 1)``. Must be
451+
lower than ping_interval.
398452
auto_reconnect : bool, default False
399453
Automatically reconnect on unexpected disconnections using exponential backoff.
400454
max_reconnect_attempts : int, default 5
@@ -408,6 +462,17 @@ class DiscoveredBleAssetsEvents(_MistWebsocket):
408462
``receive()`` generator. ``0`` means unbounded. When set,
409463
incoming messages are dropped with a warning when the queue is
410464
full, preventing memory growth on high-frequency streams.
465+
subscription_watchdog_timeout : float, default 10.0
466+
Maximum time in seconds to wait for all channel subscription
467+
acknowledgements after connect. On timeout, the error is reported to
468+
``on_error`` and the connection is closed (auto_reconnect, when
469+
enabled, then reconnects).
470+
rate_limit_backoff : float, default 30.0
471+
Minimum reconnect delay in seconds after an HTTP 429 rate-limit
472+
response.
473+
throughput_log_interval : int, default 100
474+
Log queue depth and processed counts every N messages. ``0`` disables
475+
periodic throughput logs.
411476
412477
EXAMPLE
413478
-----------
@@ -440,7 +505,7 @@ def __init__(
440505
site_id: str,
441506
map_ids: list[str],
442507
ping_interval: int = 60,
443-
ping_timeout: int = 45,
508+
ping_timeout: int | None = None,
444509
auto_reconnect: bool = False,
445510
max_reconnect_attempts: int = 5,
446511
reconnect_backoff: float = 2.0,

0 commit comments

Comments
 (0)