phoenix-channels-python-client 0.3.1__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {phoenix_channels_python_client-0.3.1/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.1}/PKG-INFO +25 -7
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/README.md +22 -6
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/__init__.py +1 -1
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client.py +27 -3
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client_types.py +4 -1
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/reconnect_controller.py +29 -7
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/supervisor.py +109 -50
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1/phoenix_channels_python_client.egg-info}/PKG-INFO +25 -7
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +1 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/pyproject.toml +3 -1
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_connection.py +5 -2
- phoenix_channels_python_client-0.4.1/tests/test_heartbeat_timeout.py +717 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect.py +111 -5
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect_policy_invariants.py +56 -5
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_shutdown_races.py +68 -9
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_supervisor_internals.py +24 -1
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/LICENSE +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/authentication.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/diagnostics.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/phx_messages.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/shutdown_signals.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/topic_runtime.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/topic_subscription.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/utils.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_channel_recovery.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_client_state.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_heartbeat.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect_stress.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_run_forever_signals.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_security.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_subscriptions.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_topic_runtime_internals.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
5
|
Author: Phoenix Channels Python Client
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,7 @@ Requires-Dist: websockets>=14.2
|
|
|
19
19
|
Provides-Extra: dev
|
|
20
20
|
Requires-Dist: pytest; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
22
|
+
Requires-Dist: looptime<0.8,>=0.7; extra == "dev"
|
|
22
23
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
23
24
|
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
24
25
|
Requires-Dist: commitizen>=3.13.0; extra == "dev"
|
|
@@ -27,6 +28,7 @@ Requires-Dist: ruff; extra == "dev"
|
|
|
27
28
|
Provides-Extra: test
|
|
28
29
|
Requires-Dist: pytest; extra == "test"
|
|
29
30
|
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
31
|
+
Requires-Dist: looptime<0.8,>=0.7; extra == "test"
|
|
30
32
|
Dynamic: license-file
|
|
31
33
|
|
|
32
34
|
# Phoenix Channels Python Client
|
|
@@ -295,7 +297,8 @@ Everything after `api_key` is keyword-only:
|
|
|
295
297
|
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
296
298
|
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
297
299
|
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
298
|
-
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
|
|
300
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats and timeout enforcement |
|
|
301
|
+
| `heartbeat_dead_threshold_s` | `None` | Optional finite positive acknowledgement-silence limit, greater than the enabled finite interval |
|
|
299
302
|
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
300
303
|
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
301
304
|
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
@@ -310,18 +313,19 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
310
313
|
|
|
311
314
|
## Connection Lifecycle and Errors
|
|
312
315
|
|
|
313
|
-
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying
|
|
314
|
-
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
316
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `PHXConnectionError`; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
|
|
317
|
+
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
315
318
|
- `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription, and `PHXConnectionError` while disconnected or if the connection drops before the join completes.
|
|
316
319
|
- `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on. It does nothing if the client isn't connected or the connection is already closing.
|
|
317
320
|
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
318
321
|
|
|
319
322
|
### Reconnects
|
|
320
323
|
|
|
321
|
-
| Close code | Behaviour (defaults) |
|
|
324
|
+
| Close code or handshake | Behaviour (defaults) |
|
|
322
325
|
|---|---|
|
|
323
|
-
| 1000, 1001 |
|
|
326
|
+
| 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
|
|
324
327
|
| 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
|
|
328
|
+
| Handshake refused with HTTP 403 | Stop with `PHXConnectionError`; other statuses (401, 5xx, …) retry with the backoff below |
|
|
325
329
|
| 1012 | Reconnect after at least a random 1–5 s |
|
|
326
330
|
| 1013 | Reconnect after at least a random 30–60 s |
|
|
327
331
|
| Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
|
|
@@ -345,7 +349,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
|
|
|
345
349
|
|
|
346
350
|
### Heartbeats
|
|
347
351
|
|
|
348
|
-
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`.
|
|
352
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. By default, missed replies log warnings and sending continues with a new reference each interval.
|
|
353
|
+
|
|
354
|
+
Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
client = PHXChannelsClient(
|
|
358
|
+
websocket_url, api_key,
|
|
359
|
+
heartbeat_interval_s=30.0,
|
|
360
|
+
heartbeat_dead_threshold_s=60.0,
|
|
361
|
+
)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The deadline starts when each socket is adopted, including during rejoin and reconnect callbacks. Only a matching pending reply received strictly before expiry renews it, before `on_heartbeat_ack` runs. Enabled enforcement keeps one outstanding heartbeat, skipping sends until it is acknowledged or expires. WebSocket Pong and unrelated messages do not renew it. Rejoin completion grants no additional grace.
|
|
365
|
+
|
|
366
|
+
Expiry or heartbeat send failure force-closes that socket; the supervisor handles cleanup and reconnect/rejoin according to `auto_reconnect` and the existing policy. The deadline bounds close initiation; the transport has its own close-handshake timeout. `heartbeat_interval_s=None` disables heartbeat activity even with a threshold, but a supplied threshold must still be finite and positive. Long thresholds do not prevent the server from applying its own inactivity timeout.
|
|
349
367
|
|
|
350
368
|
## Logging
|
|
351
369
|
|
|
@@ -264,7 +264,8 @@ Everything after `api_key` is keyword-only:
|
|
|
264
264
|
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
265
265
|
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
266
266
|
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
267
|
-
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
|
|
267
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats and timeout enforcement |
|
|
268
|
+
| `heartbeat_dead_threshold_s` | `None` | Optional finite positive acknowledgement-silence limit, greater than the enabled finite interval |
|
|
268
269
|
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
269
270
|
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
270
271
|
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
@@ -279,18 +280,19 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
279
280
|
|
|
280
281
|
## Connection Lifecycle and Errors
|
|
281
282
|
|
|
282
|
-
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying
|
|
283
|
-
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
283
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `PHXConnectionError`; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
|
|
284
|
+
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
284
285
|
- `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription, and `PHXConnectionError` while disconnected or if the connection drops before the join completes.
|
|
285
286
|
- `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on. It does nothing if the client isn't connected or the connection is already closing.
|
|
286
287
|
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
287
288
|
|
|
288
289
|
### Reconnects
|
|
289
290
|
|
|
290
|
-
| Close code | Behaviour (defaults) |
|
|
291
|
+
| Close code or handshake | Behaviour (defaults) |
|
|
291
292
|
|---|---|
|
|
292
|
-
| 1000, 1001 |
|
|
293
|
+
| 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
|
|
293
294
|
| 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
|
|
295
|
+
| Handshake refused with HTTP 403 | Stop with `PHXConnectionError`; other statuses (401, 5xx, …) retry with the backoff below |
|
|
294
296
|
| 1012 | Reconnect after at least a random 1–5 s |
|
|
295
297
|
| 1013 | Reconnect after at least a random 30–60 s |
|
|
296
298
|
| Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
|
|
@@ -314,7 +316,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
|
|
|
314
316
|
|
|
315
317
|
### Heartbeats
|
|
316
318
|
|
|
317
|
-
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`.
|
|
319
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. By default, missed replies log warnings and sending continues with a new reference each interval.
|
|
320
|
+
|
|
321
|
+
Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
client = PHXChannelsClient(
|
|
325
|
+
websocket_url, api_key,
|
|
326
|
+
heartbeat_interval_s=30.0,
|
|
327
|
+
heartbeat_dead_threshold_s=60.0,
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The deadline starts when each socket is adopted, including during rejoin and reconnect callbacks. Only a matching pending reply received strictly before expiry renews it, before `on_heartbeat_ack` runs. Enabled enforcement keeps one outstanding heartbeat, skipping sends until it is acknowledged or expires. WebSocket Pong and unrelated messages do not renew it. Rejoin completion grants no additional grace.
|
|
332
|
+
|
|
333
|
+
Expiry or heartbeat send failure force-closes that socket; the supervisor handles cleanup and reconnect/rejoin according to `auto_reconnect` and the existing policy. The deadline bounds close initiation; the transport has its own close-handshake timeout. `heartbeat_interval_s=None` disables heartbeat activity even with a threshold, but a supplied threshold must still be finite and positive. Long thresholds do not prevent the server from applying its own inactivity timeout.
|
|
318
334
|
|
|
319
335
|
## Logging
|
|
320
336
|
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import math
|
|
5
6
|
from collections import deque
|
|
6
7
|
from collections.abc import Awaitable, Callable
|
|
7
8
|
from types import TracebackType
|
|
@@ -99,6 +100,19 @@ def _build_channel_socket_urls(
|
|
|
99
100
|
)
|
|
100
101
|
|
|
101
102
|
|
|
103
|
+
def _validate_heartbeat_threshold(
|
|
104
|
+
interval: float | None, threshold: float | None
|
|
105
|
+
) -> None:
|
|
106
|
+
if threshold is None:
|
|
107
|
+
return
|
|
108
|
+
if not math.isfinite(threshold) or threshold <= 0:
|
|
109
|
+
raise ValueError("heartbeat_dead_threshold_s must be finite and > 0")
|
|
110
|
+
if interval is not None and (not math.isfinite(interval) or threshold <= interval):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"heartbeat_dead_threshold_s must exceed the finite heartbeat interval"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
102
116
|
class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
|
|
103
117
|
def __init__( # noqa: PLR0913 # the public options
|
|
104
118
|
self,
|
|
@@ -114,6 +128,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
114
128
|
max_topic_queue_size: int = 1000,
|
|
115
129
|
callback_drain_timeout_s: float = 2.0,
|
|
116
130
|
heartbeat_interval_s: float | None = 30.0,
|
|
131
|
+
heartbeat_dead_threshold_s: float | None = None,
|
|
117
132
|
on_reconnect: ReconnectCallback | None = None,
|
|
118
133
|
on_disconnect: DisconnectCallback | None = None,
|
|
119
134
|
on_heartbeat_ack: HeartbeatAckCallback | None = None,
|
|
@@ -128,6 +143,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
128
143
|
if heartbeat_interval_s is not None and heartbeat_interval_s <= 0:
|
|
129
144
|
raise ValueError("heartbeat_interval_s must be > 0 or None to disable")
|
|
130
145
|
|
|
146
|
+
_validate_heartbeat_threshold(heartbeat_interval_s, heartbeat_dead_threshold_s)
|
|
147
|
+
|
|
131
148
|
if join_timeout_s <= 0:
|
|
132
149
|
raise ValueError("join_timeout_s must be > 0")
|
|
133
150
|
if leave_timeout_s <= 0:
|
|
@@ -170,6 +187,9 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
170
187
|
self._initial_connection_future: asyncio.Future[None] | None = None
|
|
171
188
|
self._rapid_disconnects: deque[float] = deque()
|
|
172
189
|
self._terminal_error: Exception | None = None
|
|
190
|
+
self._heartbeat_dead_threshold_s = heartbeat_dead_threshold_s
|
|
191
|
+
self._heartbeat_deadline: float | None = None
|
|
192
|
+
self._heartbeat_timeout: asyncio.Timeout | None = None
|
|
173
193
|
self._heartbeat_interval_s = heartbeat_interval_s
|
|
174
194
|
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
175
195
|
self._pending_heartbeat_ref: str | None = None
|
|
@@ -234,18 +254,22 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
234
254
|
if task is None:
|
|
235
255
|
if self._is_fully_closed:
|
|
236
256
|
return
|
|
257
|
+
self._request_stop()
|
|
237
258
|
task = self._shutdown_task = asyncio.create_task(self._shutdown(reason))
|
|
238
259
|
# Every caller waits for the same shutdown, and one caller's
|
|
239
260
|
# cancellation never aborts it.
|
|
240
261
|
await asyncio.shield(task)
|
|
241
262
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
263
|
+
def _request_stop(self) -> None:
|
|
264
|
+
# Runs inside the shutdown() call, not in its task: a refusal or close landing
|
|
265
|
+
# before the task's first step must already see the stop.
|
|
245
266
|
if self._state not in (ClientState.SHUTTING_DOWN, ClientState.CLOSED):
|
|
246
267
|
self._transition_state(ClientState.SHUTTING_DOWN)
|
|
247
268
|
self._shutdown_event.set()
|
|
248
269
|
|
|
270
|
+
async def _shutdown(self, reason: str) -> None:
|
|
271
|
+
self.logger.info("Event loop shutting down! reason=%s", reason)
|
|
272
|
+
|
|
249
273
|
await self._unsubscribe_all()
|
|
250
274
|
# Includes on_topic_lost callbacks still running for topics already gone.
|
|
251
275
|
await cancel_and_wait(*self._lifecycle_tasks)
|
|
@@ -40,7 +40,10 @@ class ReconnectPolicy(BaseModel):
|
|
|
40
40
|
factor: PositiveFloat = 2.0
|
|
41
41
|
max_delay_s: NonNegativeFloat = 30.0
|
|
42
42
|
stable_reset_s: PositiveFloat = 60.0
|
|
43
|
-
|
|
43
|
+
# Phoenix >= 1.8.8 closes deliberate disconnects with 1001 ("reconnect
|
|
44
|
+
# expected"), and Bandit answers a close the peer started with 1000, so
|
|
45
|
+
# neither code is a stop signal.
|
|
46
|
+
reconnect_on_normal_close: bool = True
|
|
44
47
|
policy_violation_is_terminal: bool = True
|
|
45
48
|
service_restart_min_delay_s: NonNegativeFloat = 1.0
|
|
46
49
|
service_restart_max_delay_s: NonNegativeFloat = 5.0
|
|
@@ -4,9 +4,10 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import random
|
|
6
6
|
from collections import deque
|
|
7
|
+
from http import HTTPStatus
|
|
7
8
|
|
|
8
9
|
from websockets import ClientConnection
|
|
9
|
-
from websockets.exceptions import ConnectionClosed
|
|
10
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
|
10
11
|
from websockets.frames import CloseCode
|
|
11
12
|
|
|
12
13
|
from phoenix_channels_python_client.client_types import (
|
|
@@ -15,6 +16,20 @@ from phoenix_channels_python_client.client_types import (
|
|
|
15
16
|
)
|
|
16
17
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
17
18
|
|
|
19
|
+
# The Band platform answers every auth refusal with 403, and its websocket spec
|
|
20
|
+
# (thenvoi-platform spec/websocket-api.draft.md) makes a rejected reconnect terminal.
|
|
21
|
+
# A 401 could only come from an edge layer, so it and every other status retry.
|
|
22
|
+
TERMINAL_HANDSHAKE_STATUS = HTTPStatus.FORBIDDEN
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _terminal_decision(cause: str) -> ReconnectDecision:
|
|
26
|
+
return ReconnectDecision(
|
|
27
|
+
should_reconnect=False,
|
|
28
|
+
terminal_error=PHXConnectionError(
|
|
29
|
+
f"Reconnect disabled due to terminal {cause}"
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
18
33
|
|
|
19
34
|
class ReconnectControllerMixin:
|
|
20
35
|
logger: logging.Logger
|
|
@@ -211,12 +226,8 @@ class ReconnectControllerMixin:
|
|
|
211
226
|
and self.reconnect_policy.policy_violation_is_terminal
|
|
212
227
|
):
|
|
213
228
|
reason = close_reason or "policy violation"
|
|
214
|
-
decision =
|
|
215
|
-
|
|
216
|
-
terminal_error=PHXConnectionError(
|
|
217
|
-
"Reconnect disabled due to terminal close code "
|
|
218
|
-
f"{CloseCode.POLICY_VIOLATION} ({reason})"
|
|
219
|
-
),
|
|
229
|
+
decision = _terminal_decision(
|
|
230
|
+
f"close code {CloseCode.POLICY_VIOLATION} ({reason})"
|
|
220
231
|
)
|
|
221
232
|
self.logger.debug(
|
|
222
233
|
"Disconnect classification result. close_code=%s close_reason=%s "
|
|
@@ -297,6 +308,17 @@ class ReconnectControllerMixin:
|
|
|
297
308
|
)
|
|
298
309
|
return decision
|
|
299
310
|
|
|
311
|
+
def _classify_connect_failure(self, error: Exception) -> ReconnectDecision:
|
|
312
|
+
if (
|
|
313
|
+
isinstance(error, InvalidStatus)
|
|
314
|
+
and error.response.status_code == TERMINAL_HANDSHAKE_STATUS
|
|
315
|
+
):
|
|
316
|
+
return _terminal_decision(
|
|
317
|
+
f"handshake status {TERMINAL_HANDSHAKE_STATUS} "
|
|
318
|
+
f"({TERMINAL_HANDSHAKE_STATUS.phrase})"
|
|
319
|
+
)
|
|
320
|
+
return ReconnectDecision(should_reconnect=True)
|
|
321
|
+
|
|
300
322
|
def _apply_disconnect_delay_override(
|
|
301
323
|
self,
|
|
302
324
|
computed_delay_s: float,
|
|
@@ -67,6 +67,7 @@ class _SupervisorRuntimeDeps(Protocol):
|
|
|
67
67
|
def _classify_disconnect(
|
|
68
68
|
self, close_code: int | None, close_reason: str
|
|
69
69
|
) -> ReconnectDecision: ...
|
|
70
|
+
def _classify_connect_failure(self, error: Exception) -> ReconnectDecision: ...
|
|
70
71
|
def _apply_disconnect_delay_override(
|
|
71
72
|
self, computed_delay_s: float, decision: ReconnectDecision
|
|
72
73
|
) -> float: ...
|
|
@@ -93,6 +94,9 @@ class SupervisorMixin:
|
|
|
93
94
|
_initial_connection_future: asyncio.Future[None] | None
|
|
94
95
|
_rapid_disconnects: deque[float]
|
|
95
96
|
_terminal_error: Exception | None
|
|
97
|
+
_heartbeat_dead_threshold_s: float | None
|
|
98
|
+
_heartbeat_deadline: float | None
|
|
99
|
+
_heartbeat_timeout: asyncio.Timeout | None
|
|
96
100
|
_heartbeat_interval_s: float | None
|
|
97
101
|
_heartbeat_task: asyncio.Task[None] | None
|
|
98
102
|
_pending_heartbeat_ref: str | None
|
|
@@ -117,6 +121,17 @@ class SupervisorMixin:
|
|
|
117
121
|
|
|
118
122
|
def _handle_heartbeat_response(self, message: ChannelMessage) -> None:
|
|
119
123
|
if message.ref is not None and message.ref == self._pending_heartbeat_ref:
|
|
124
|
+
if self._heartbeat_dead_threshold_s is not None:
|
|
125
|
+
timer = self._heartbeat_timeout
|
|
126
|
+
now = asyncio.get_running_loop().time()
|
|
127
|
+
if (
|
|
128
|
+
timer is None
|
|
129
|
+
or timer.expired()
|
|
130
|
+
or (deadline := timer.when()) is None
|
|
131
|
+
or now >= deadline
|
|
132
|
+
):
|
|
133
|
+
return
|
|
134
|
+
timer.reschedule(now + self._heartbeat_dead_threshold_s)
|
|
120
135
|
self.logger.debug("Heartbeat acknowledged (ref=%s)", message.ref)
|
|
121
136
|
self._pending_heartbeat_ref = None
|
|
122
137
|
if self._on_heartbeat_ack is not None:
|
|
@@ -148,6 +163,11 @@ class SupervisorMixin:
|
|
|
148
163
|
connection = self.connection
|
|
149
164
|
if connection is None:
|
|
150
165
|
return
|
|
166
|
+
await self._force_close_connection(connection, reason)
|
|
167
|
+
|
|
168
|
+
async def _force_close_connection(
|
|
169
|
+
self, connection: ClientConnection, reason: str
|
|
170
|
+
) -> None:
|
|
151
171
|
if connection.state is not State.OPEN:
|
|
152
172
|
self.logger.debug(
|
|
153
173
|
"Not forcing a close; connection is already %s", connection.state.name
|
|
@@ -156,52 +176,67 @@ class SupervisorMixin:
|
|
|
156
176
|
reason = _truncate_close_reason(reason)
|
|
157
177
|
self.logger.info("Forcing connection close: %s", reason)
|
|
158
178
|
self._forced_close_pending = True
|
|
159
|
-
await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
|
|
160
|
-
|
|
161
|
-
async def _heartbeat_loop(self, connection: ClientConnection) -> None:
|
|
162
|
-
if self._heartbeat_interval_s is None:
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
self.logger.debug(
|
|
166
|
-
"Starting heartbeat loop (interval=%ss)", self._heartbeat_interval_s
|
|
167
|
-
)
|
|
168
|
-
|
|
169
179
|
try:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
180
|
+
await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
|
|
181
|
+
except (Exception, asyncio.CancelledError):
|
|
182
|
+
connection.transport.abort()
|
|
183
|
+
raise
|
|
174
184
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
185
|
+
async def _heartbeat_loop(
|
|
186
|
+
self, connection: ClientConnection, deadline: float | None = None
|
|
187
|
+
) -> None:
|
|
188
|
+
timer = asyncio.timeout_at(deadline)
|
|
189
|
+
try:
|
|
190
|
+
async with timer:
|
|
191
|
+
self._heartbeat_timeout = timer
|
|
192
|
+
try:
|
|
193
|
+
await self._send_heartbeats(connection)
|
|
194
|
+
finally:
|
|
195
|
+
self._heartbeat_timeout = None
|
|
196
|
+
except TimeoutError:
|
|
197
|
+
if timer.expired():
|
|
198
|
+
self.logger.warning("Heartbeat acknowledgement deadline expired")
|
|
199
|
+
elif self._heartbeat_dead_threshold_s is not None:
|
|
200
|
+
self.logger.exception("Heartbeat send timed out")
|
|
201
|
+
except Exception:
|
|
202
|
+
if self._heartbeat_dead_threshold_s is None:
|
|
203
|
+
self.logger.debug("Failed to send heartbeat; connection likely closing")
|
|
204
|
+
return
|
|
205
|
+
self.logger.exception("Failed to send heartbeat")
|
|
206
|
+
else:
|
|
207
|
+
return
|
|
181
208
|
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
if self._heartbeat_dead_threshold_s is not None:
|
|
210
|
+
try:
|
|
211
|
+
await self._force_close_connection(connection, "Heartbeat failed")
|
|
212
|
+
except Exception:
|
|
213
|
+
self.logger.exception("Failed to close heartbeat connection")
|
|
184
214
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
215
|
+
async def _send_heartbeats(self, connection: ClientConnection) -> None:
|
|
216
|
+
interval = self._heartbeat_interval_s
|
|
217
|
+
if interval is None:
|
|
218
|
+
return
|
|
219
|
+
while not self._shutdown_event.is_set():
|
|
220
|
+
await self._wait_for_shutdown_or_timeout(interval)
|
|
221
|
+
if self._shutdown_event.is_set():
|
|
222
|
+
return
|
|
223
|
+
if self._pending_heartbeat_ref is not None:
|
|
224
|
+
if self._heartbeat_dead_threshold_s is not None:
|
|
225
|
+
continue
|
|
226
|
+
self.logger.warning(
|
|
227
|
+
"Heartbeat response not received for ref=%s; server may be "
|
|
228
|
+
"unresponsive",
|
|
229
|
+
self._pending_heartbeat_ref,
|
|
190
230
|
)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
)
|
|
201
|
-
break
|
|
202
|
-
except asyncio.CancelledError:
|
|
203
|
-
self.logger.debug("Heartbeat loop cancelled")
|
|
204
|
-
raise
|
|
231
|
+
ref = self._deps._generate_ref()
|
|
232
|
+
self._pending_heartbeat_ref = ref
|
|
233
|
+
await self._protocol_handler.send_message(
|
|
234
|
+
connection,
|
|
235
|
+
make_message(
|
|
236
|
+
topic=PHOENIX_TOPIC, event=HEARTBEAT_EVENT, payload={}, ref=ref
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
self.logger.debug("Sent heartbeat (ref=%s)", ref)
|
|
205
240
|
|
|
206
241
|
async def _supervisor_loop(self) -> None:
|
|
207
242
|
attempt = 0
|
|
@@ -262,10 +297,19 @@ class SupervisorMixin:
|
|
|
262
297
|
)
|
|
263
298
|
self.logger.error("Connection failed and auto_reconnect=False: %s", exc)
|
|
264
299
|
return False
|
|
300
|
+
# A requested stop outranks a refusal's terminal error.
|
|
301
|
+
if self._shutdown_event.is_set():
|
|
302
|
+
self.logger.debug(
|
|
303
|
+
"Ignoring a connect failure after shutdown began: %s", exc
|
|
304
|
+
)
|
|
305
|
+
return False
|
|
306
|
+
decision = self._deps._classify_connect_failure(exc)
|
|
307
|
+
if decision.terminal_error is not None:
|
|
308
|
+
self._record_terminal_error(decision.terminal_error)
|
|
309
|
+
return False
|
|
265
310
|
if self._suppressed_after_disconnect(0.0):
|
|
266
311
|
return False
|
|
267
|
-
|
|
268
|
-
self._deps._transition_state(ClientState.RECONNECTING)
|
|
312
|
+
self._deps._transition_state(ClientState.RECONNECTING)
|
|
269
313
|
return True
|
|
270
314
|
|
|
271
315
|
def _adopt_connection(self, connection: ClientConnection) -> bool:
|
|
@@ -274,6 +318,12 @@ class SupervisorMixin:
|
|
|
274
318
|
# shutdown() began during the handshake; its own cleanup closes
|
|
275
319
|
# this socket.
|
|
276
320
|
return False
|
|
321
|
+
self._heartbeat_deadline = (
|
|
322
|
+
asyncio.get_running_loop().time() + self._heartbeat_dead_threshold_s
|
|
323
|
+
if self._heartbeat_interval_s is not None
|
|
324
|
+
and self._heartbeat_dead_threshold_s is not None
|
|
325
|
+
else None
|
|
326
|
+
)
|
|
277
327
|
self._conn_generation += 1
|
|
278
328
|
self._connected_event.set()
|
|
279
329
|
self._deps._transition_state(ClientState.CONNECTED)
|
|
@@ -289,7 +339,9 @@ class SupervisorMixin:
|
|
|
289
339
|
# a rejoin is still waiting on one, before cleanup runs.
|
|
290
340
|
routing.add_done_callback(self._fail_joins_when_routing_stops)
|
|
291
341
|
if self._heartbeat_interval_s is not None:
|
|
292
|
-
self._heartbeat_task = asyncio.create_task(
|
|
342
|
+
self._heartbeat_task = asyncio.create_task(
|
|
343
|
+
self._heartbeat_loop(connection, self._heartbeat_deadline)
|
|
344
|
+
)
|
|
293
345
|
|
|
294
346
|
try:
|
|
295
347
|
await self._deps._rejoin_topics(generation)
|
|
@@ -347,8 +399,7 @@ class SupervisorMixin:
|
|
|
347
399
|
else:
|
|
348
400
|
decision = self._deps._classify_disconnect(close.code, close.reason)
|
|
349
401
|
if decision.terminal_error is not None:
|
|
350
|
-
self.
|
|
351
|
-
self.logger.error("%s", self._terminal_error)
|
|
402
|
+
self._record_terminal_error(decision.terminal_error)
|
|
352
403
|
return None
|
|
353
404
|
|
|
354
405
|
if not decision.should_reconnect:
|
|
@@ -364,13 +415,20 @@ class SupervisorMixin:
|
|
|
364
415
|
self._deps._record_disconnect(connection_uptime_s=uptime_s)
|
|
365
416
|
if not self._deps._should_suppress_reconnect():
|
|
366
417
|
return False
|
|
367
|
-
self.
|
|
368
|
-
|
|
369
|
-
|
|
418
|
+
self._record_terminal_error(
|
|
419
|
+
PHXConnectionError(
|
|
420
|
+
"Reconnect suppressed after repeated rapid disconnects. "
|
|
421
|
+
"Likely duplicate connection or unstable endpoint."
|
|
422
|
+
)
|
|
370
423
|
)
|
|
371
|
-
self.logger.error("%s", self._terminal_error)
|
|
372
424
|
return True
|
|
373
425
|
|
|
426
|
+
def _record_terminal_error(self, error: PHXConnectionError) -> None:
|
|
427
|
+
self._terminal_error = error
|
|
428
|
+
# Log records stay static: the cause reaches the caller as the raised error.
|
|
429
|
+
self.logger.error("Reconnect stopped by a terminal error")
|
|
430
|
+
self._settle_initial_connection(error)
|
|
431
|
+
|
|
374
432
|
def _settle_initial_connection(self, error: Exception | None) -> None:
|
|
375
433
|
"""Resolve ``__aenter__``'s wait, unless it already ended or was cancelled."""
|
|
376
434
|
future = self._initial_connection_future
|
|
@@ -416,6 +474,7 @@ class SupervisorMixin:
|
|
|
416
474
|
]
|
|
417
475
|
self.connection = None
|
|
418
476
|
self._heartbeat_task = None
|
|
477
|
+
self._heartbeat_deadline = None
|
|
419
478
|
self._message_routing_task = None
|
|
420
479
|
self._pending_heartbeat_ref = None
|
|
421
480
|
self._connected_event.clear()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
5
|
Author: Phoenix Channels Python Client
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,7 @@ Requires-Dist: websockets>=14.2
|
|
|
19
19
|
Provides-Extra: dev
|
|
20
20
|
Requires-Dist: pytest; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
22
|
+
Requires-Dist: looptime<0.8,>=0.7; extra == "dev"
|
|
22
23
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
23
24
|
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
24
25
|
Requires-Dist: commitizen>=3.13.0; extra == "dev"
|
|
@@ -27,6 +28,7 @@ Requires-Dist: ruff; extra == "dev"
|
|
|
27
28
|
Provides-Extra: test
|
|
28
29
|
Requires-Dist: pytest; extra == "test"
|
|
29
30
|
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
31
|
+
Requires-Dist: looptime<0.8,>=0.7; extra == "test"
|
|
30
32
|
Dynamic: license-file
|
|
31
33
|
|
|
32
34
|
# Phoenix Channels Python Client
|
|
@@ -295,7 +297,8 @@ Everything after `api_key` is keyword-only:
|
|
|
295
297
|
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
296
298
|
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
297
299
|
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
298
|
-
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
|
|
300
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats and timeout enforcement |
|
|
301
|
+
| `heartbeat_dead_threshold_s` | `None` | Optional finite positive acknowledgement-silence limit, greater than the enabled finite interval |
|
|
299
302
|
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
300
303
|
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
301
304
|
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
@@ -310,18 +313,19 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
310
313
|
|
|
311
314
|
## Connection Lifecycle and Errors
|
|
312
315
|
|
|
313
|
-
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying
|
|
314
|
-
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
316
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `PHXConnectionError`; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
|
|
317
|
+
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
315
318
|
- `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription, and `PHXConnectionError` while disconnected or if the connection drops before the join completes.
|
|
316
319
|
- `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on. It does nothing if the client isn't connected or the connection is already closing.
|
|
317
320
|
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
318
321
|
|
|
319
322
|
### Reconnects
|
|
320
323
|
|
|
321
|
-
| Close code | Behaviour (defaults) |
|
|
324
|
+
| Close code or handshake | Behaviour (defaults) |
|
|
322
325
|
|---|---|
|
|
323
|
-
| 1000, 1001 |
|
|
326
|
+
| 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
|
|
324
327
|
| 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
|
|
328
|
+
| Handshake refused with HTTP 403 | Stop with `PHXConnectionError`; other statuses (401, 5xx, …) retry with the backoff below |
|
|
325
329
|
| 1012 | Reconnect after at least a random 1–5 s |
|
|
326
330
|
| 1013 | Reconnect after at least a random 30–60 s |
|
|
327
331
|
| Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
|
|
@@ -345,7 +349,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
|
|
|
345
349
|
|
|
346
350
|
### Heartbeats
|
|
347
351
|
|
|
348
|
-
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`.
|
|
352
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. By default, missed replies log warnings and sending continues with a new reference each interval.
|
|
353
|
+
|
|
354
|
+
Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
client = PHXChannelsClient(
|
|
358
|
+
websocket_url, api_key,
|
|
359
|
+
heartbeat_interval_s=30.0,
|
|
360
|
+
heartbeat_dead_threshold_s=60.0,
|
|
361
|
+
)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The deadline starts when each socket is adopted, including during rejoin and reconnect callbacks. Only a matching pending reply received strictly before expiry renews it, before `on_heartbeat_ack` runs. Enabled enforcement keeps one outstanding heartbeat, skipping sends until it is acknowledged or expires. WebSocket Pong and unrelated messages do not renew it. Rejoin completion grants no additional grace.
|
|
365
|
+
|
|
366
|
+
Expiry or heartbeat send failure force-closes that socket; the supervisor handles cleanup and reconnect/rejoin according to `auto_reconnect` and the existing policy. The deadline bounds close initiation; the transport has its own close-handshake timeout. `heartbeat_interval_s=None` disables heartbeat activity even with a threshold, but a supplied threshold must still be finite and positive. Long thresholds do not prevent the server from applying its own inactivity timeout.
|
|
349
367
|
|
|
350
368
|
## Logging
|
|
351
369
|
|