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.
Files changed (42) hide show
  1. {phoenix_channels_python_client-0.3.1/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.1}/PKG-INFO +25 -7
  2. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/README.md +22 -6
  3. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/__init__.py +1 -1
  4. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client.py +27 -3
  5. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client_types.py +4 -1
  6. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/reconnect_controller.py +29 -7
  7. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/supervisor.py +109 -50
  8. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1/phoenix_channels_python_client.egg-info}/PKG-INFO +25 -7
  9. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +1 -0
  10. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
  11. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/pyproject.toml +3 -1
  12. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_connection.py +5 -2
  13. phoenix_channels_python_client-0.4.1/tests/test_heartbeat_timeout.py +717 -0
  14. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect.py +111 -5
  15. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect_policy_invariants.py +56 -5
  16. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_shutdown_races.py +68 -9
  17. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_supervisor_internals.py +24 -1
  18. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/LICENSE +0 -0
  19. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/MANIFEST.in +0 -0
  20. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/authentication.py +0 -0
  21. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client_state_machine.py +0 -0
  22. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/diagnostics.py +0 -0
  23. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/exceptions.py +0 -0
  24. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/phx_messages.py +0 -0
  25. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/protocol_handler.py +0 -0
  26. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  27. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/topic_runtime.py +0 -0
  28. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/topic_subscription.py +0 -0
  29. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/utils.py +0 -0
  30. {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
  31. {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
  32. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/setup.cfg +0 -0
  33. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_cancel_and_wait.py +0 -0
  34. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_channel_recovery.py +0 -0
  35. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_client_state.py +0 -0
  36. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_heartbeat.py +0 -0
  37. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_protocol_handler.py +0 -0
  38. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect_stress.py +0 -0
  39. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_run_forever_signals.py +0 -0
  40. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_security.py +0 -0
  41. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.1}/tests/test_subscriptions.py +0 -0
  42. {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.1
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; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
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 | Stop, unless `reconnect_on_normal_close=True` |
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`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
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; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
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 | Stop, unless `reconnect_on_normal_close=True` |
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`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
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
 
@@ -19,7 +19,7 @@ from phoenix_channels_python_client.protocol_handler import (
19
19
  )
20
20
  from phoenix_channels_python_client.utils import setup_logging
21
21
 
22
- __version__ = "0.3.1"
22
+ __version__ = "0.4.1"
23
23
  __author__ = "Phoenix Channels Python Client"
24
24
 
25
25
  __all__ = [
@@ -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
- async def _shutdown(self, reason: str) -> None:
243
- self.logger.info("Event loop shutting down! reason=%s", reason)
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
- reconnect_on_normal_close: bool = False
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 = ReconnectDecision(
215
- should_reconnect=False,
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
- while not self._shutdown_event.is_set():
171
- await self._wait_for_shutdown_or_timeout(self._heartbeat_interval_s)
172
- if self._shutdown_event.is_set():
173
- break
180
+ await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
181
+ except (Exception, asyncio.CancelledError):
182
+ connection.transport.abort()
183
+ raise
174
184
 
175
- if self._pending_heartbeat_ref is not None:
176
- self.logger.warning(
177
- "Heartbeat response not received for ref=%s; server may be "
178
- "unresponsive",
179
- self._pending_heartbeat_ref,
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
- ref = self._deps._generate_ref()
183
- self._pending_heartbeat_ref = ref
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
- heartbeat_message = make_message(
186
- topic=PHOENIX_TOPIC,
187
- event=HEARTBEAT_EVENT,
188
- payload={},
189
- ref=ref,
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
- try:
193
- await self._protocol_handler.send_message(
194
- connection, heartbeat_message
195
- )
196
- self.logger.debug("Sent heartbeat (ref=%s)", ref)
197
- except Exception: # noqa: BLE001 # any send failure ends the loop
198
- self.logger.debug(
199
- "Failed to send heartbeat; connection likely closing"
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
- if self._state != ClientState.SHUTTING_DOWN:
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(self._heartbeat_loop(connection))
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._terminal_error = decision.terminal_error
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._terminal_error = PHXConnectionError(
368
- "Reconnect suppressed after repeated rapid disconnects. "
369
- "Likely duplicate connection or unstable endpoint."
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.1
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; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
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 | Stop, unless `reconnect_on_normal_close=True` |
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`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
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
 
@@ -27,6 +27,7 @@ tests/test_channel_recovery.py
27
27
  tests/test_client_state.py
28
28
  tests/test_connection.py
29
29
  tests/test_heartbeat.py
30
+ tests/test_heartbeat_timeout.py
30
31
  tests/test_protocol_handler.py
31
32
  tests/test_reconnect.py
32
33
  tests/test_reconnect_policy_invariants.py
@@ -4,6 +4,7 @@ websockets>=14.2
4
4
  [dev]
5
5
  pytest
6
6
  pytest-asyncio>=1.4
7
+ looptime<0.8,>=0.7
7
8
  pytest-cov
8
9
  pre-commit>=3.0.0
9
10
  commitizen>=3.13.0
@@ -13,3 +14,4 @@ ruff
13
14
  [test]
14
15
  pytest
15
16
  pytest-asyncio>=1.4
17
+ looptime<0.8,>=0.7