phoenix-channels-python-client 0.4.0__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.4.0 → phoenix_channels_python_client-0.4.1}/PKG-INFO +20 -3
  2. phoenix_channels_python_client-0.4.0/phoenix_channels_python_client.egg-info/PKG-INFO → phoenix_channels_python_client-0.4.1/README.md +17 -33
  3. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/__init__.py +1 -1
  4. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client.py +20 -0
  5. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/supervisor.py +85 -42
  6. phoenix_channels_python_client-0.4.0/README.md → phoenix_channels_python_client-0.4.1/phoenix_channels_python_client.egg-info/PKG-INFO +50 -2
  7. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +1 -0
  8. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
  9. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/pyproject.toml +3 -1
  10. phoenix_channels_python_client-0.4.1/tests/test_heartbeat_timeout.py +717 -0
  11. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_shutdown_races.py +14 -6
  12. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/LICENSE +0 -0
  13. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/MANIFEST.in +0 -0
  14. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/authentication.py +0 -0
  15. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client_state_machine.py +0 -0
  16. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client_types.py +0 -0
  17. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/diagnostics.py +0 -0
  18. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/exceptions.py +0 -0
  19. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/phx_messages.py +0 -0
  20. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/protocol_handler.py +0 -0
  21. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/reconnect_controller.py +0 -0
  22. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  23. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/topic_runtime.py +0 -0
  24. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/topic_subscription.py +0 -0
  25. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/utils.py +0 -0
  26. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  27. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  28. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/setup.cfg +0 -0
  29. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_cancel_and_wait.py +0 -0
  30. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_channel_recovery.py +0 -0
  31. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_client_state.py +0 -0
  32. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_connection.py +0 -0
  33. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_heartbeat.py +0 -0
  34. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_protocol_handler.py +0 -0
  35. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect.py +0 -0
  36. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect_policy_invariants.py +0 -0
  37. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect_stress.py +0 -0
  38. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_run_forever_signals.py +0 -0
  39. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_security.py +0 -0
  40. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_subscriptions.py +0 -0
  41. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_supervisor_internals.py +0 -0
  42. {phoenix_channels_python_client-0.4.0 → 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.4.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 |
@@ -346,7 +349,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
346
349
 
347
350
  ### Heartbeats
348
351
 
349
- 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.
350
367
 
351
368
  ## Logging
352
369
 
@@ -1,34 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: phoenix-channels-python-client
3
- Version: 0.4.0
4
- Summary: A Python client library for connecting to Phoenix Channels
5
- Author: Phoenix Channels Python Client
6
- License-Expression: MIT
7
- Classifier: Development Status :: 3 - Alpha
8
- Classifier: Intended Audience :: Developers
9
- Classifier: Operating System :: OS Independent
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.11
12
- Classifier: Programming Language :: Python :: 3.12
13
- Classifier: Programming Language :: Python :: 3.13
14
- Requires-Python: >=3.11
15
- Description-Content-Type: text/markdown
16
- License-File: LICENSE
17
- Requires-Dist: pydantic>=2.8
18
- Requires-Dist: websockets>=14.2
19
- Provides-Extra: dev
20
- Requires-Dist: pytest; extra == "dev"
21
- Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
22
- Requires-Dist: pytest-cov; extra == "dev"
23
- Requires-Dist: pre-commit>=3.0.0; extra == "dev"
24
- Requires-Dist: commitizen>=3.13.0; extra == "dev"
25
- Requires-Dist: pyrefly; extra == "dev"
26
- Requires-Dist: ruff; extra == "dev"
27
- Provides-Extra: test
28
- Requires-Dist: pytest; extra == "test"
29
- Requires-Dist: pytest-asyncio>=1.4; extra == "test"
30
- Dynamic: license-file
31
-
32
1
  # Phoenix Channels Python Client
33
2
 
34
3
  [![PyPI version](https://img.shields.io/pypi/v/phoenix-channels-python-client.svg)](https://pypi.org/project/phoenix-channels-python-client/)
@@ -295,7 +264,8 @@ Everything after `api_key` is keyword-only:
295
264
  | `protocol_version` | `V2` | Phoenix Channels protocol version |
296
265
  | `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
297
266
  | `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
298
- | `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 |
299
269
  | `join_timeout_s` | `10.0` | Wait for a join reply |
300
270
  | `leave_timeout_s` | `5.0` | Wait for a leave reply |
301
271
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
@@ -346,7 +316,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
346
316
 
347
317
  ### Heartbeats
348
318
 
349
- 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.
350
334
 
351
335
  ## Logging
352
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.4.0"
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
@@ -94,6 +94,9 @@ class SupervisorMixin:
94
94
  _initial_connection_future: asyncio.Future[None] | None
95
95
  _rapid_disconnects: deque[float]
96
96
  _terminal_error: Exception | None
97
+ _heartbeat_dead_threshold_s: float | None
98
+ _heartbeat_deadline: float | None
99
+ _heartbeat_timeout: asyncio.Timeout | None
97
100
  _heartbeat_interval_s: float | None
98
101
  _heartbeat_task: asyncio.Task[None] | None
99
102
  _pending_heartbeat_ref: str | None
@@ -118,6 +121,17 @@ class SupervisorMixin:
118
121
 
119
122
  def _handle_heartbeat_response(self, message: ChannelMessage) -> None:
120
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)
121
135
  self.logger.debug("Heartbeat acknowledged (ref=%s)", message.ref)
122
136
  self._pending_heartbeat_ref = None
123
137
  if self._on_heartbeat_ack is not None:
@@ -149,6 +163,11 @@ class SupervisorMixin:
149
163
  connection = self.connection
150
164
  if connection is None:
151
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:
152
171
  if connection.state is not State.OPEN:
153
172
  self.logger.debug(
154
173
  "Not forcing a close; connection is already %s", connection.state.name
@@ -157,52 +176,67 @@ class SupervisorMixin:
157
176
  reason = _truncate_close_reason(reason)
158
177
  self.logger.info("Forcing connection close: %s", reason)
159
178
  self._forced_close_pending = True
160
- await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
161
-
162
- async def _heartbeat_loop(self, connection: ClientConnection) -> None:
163
- if self._heartbeat_interval_s is None:
164
- return
165
-
166
- self.logger.debug(
167
- "Starting heartbeat loop (interval=%ss)", self._heartbeat_interval_s
168
- )
169
-
170
179
  try:
171
- while not self._shutdown_event.is_set():
172
- await self._wait_for_shutdown_or_timeout(self._heartbeat_interval_s)
173
- if self._shutdown_event.is_set():
174
- break
180
+ await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
181
+ except (Exception, asyncio.CancelledError):
182
+ connection.transport.abort()
183
+ raise
175
184
 
176
- if self._pending_heartbeat_ref is not None:
177
- self.logger.warning(
178
- "Heartbeat response not received for ref=%s; server may be "
179
- "unresponsive",
180
- self._pending_heartbeat_ref,
181
- )
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
182
208
 
183
- ref = self._deps._generate_ref()
184
- 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")
185
214
 
186
- heartbeat_message = make_message(
187
- topic=PHOENIX_TOPIC,
188
- event=HEARTBEAT_EVENT,
189
- payload={},
190
- 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,
191
230
  )
192
-
193
- try:
194
- await self._protocol_handler.send_message(
195
- connection, heartbeat_message
196
- )
197
- self.logger.debug("Sent heartbeat (ref=%s)", ref)
198
- except Exception: # noqa: BLE001 # any send failure ends the loop
199
- self.logger.debug(
200
- "Failed to send heartbeat; connection likely closing"
201
- )
202
- break
203
- except asyncio.CancelledError:
204
- self.logger.debug("Heartbeat loop cancelled")
205
- 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)
206
240
 
207
241
  async def _supervisor_loop(self) -> None:
208
242
  attempt = 0
@@ -284,6 +318,12 @@ class SupervisorMixin:
284
318
  # shutdown() began during the handshake; its own cleanup closes
285
319
  # this socket.
286
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
+ )
287
327
  self._conn_generation += 1
288
328
  self._connected_event.set()
289
329
  self._deps._transition_state(ClientState.CONNECTED)
@@ -299,7 +339,9 @@ class SupervisorMixin:
299
339
  # a rejoin is still waiting on one, before cleanup runs.
300
340
  routing.add_done_callback(self._fail_joins_when_routing_stops)
301
341
  if self._heartbeat_interval_s is not None:
302
- 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
+ )
303
345
 
304
346
  try:
305
347
  await self._deps._rejoin_topics(generation)
@@ -432,6 +474,7 @@ class SupervisorMixin:
432
474
  ]
433
475
  self.connection = None
434
476
  self._heartbeat_task = None
477
+ self._heartbeat_deadline = None
435
478
  self._message_routing_task = None
436
479
  self._pending_heartbeat_ref = None
437
480
  self._connected_event.clear()
@@ -1,3 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: phoenix-channels-python-client
3
+ Version: 0.4.1
4
+ Summary: A Python client library for connecting to Phoenix Channels
5
+ Author: Phoenix Channels Python Client
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pydantic>=2.8
18
+ Requires-Dist: websockets>=14.2
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
22
+ Requires-Dist: looptime<0.8,>=0.7; extra == "dev"
23
+ Requires-Dist: pytest-cov; extra == "dev"
24
+ Requires-Dist: pre-commit>=3.0.0; extra == "dev"
25
+ Requires-Dist: commitizen>=3.13.0; extra == "dev"
26
+ Requires-Dist: pyrefly; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Provides-Extra: test
29
+ Requires-Dist: pytest; extra == "test"
30
+ Requires-Dist: pytest-asyncio>=1.4; extra == "test"
31
+ Requires-Dist: looptime<0.8,>=0.7; extra == "test"
32
+ Dynamic: license-file
33
+
1
34
  # Phoenix Channels Python Client
2
35
 
3
36
  [![PyPI version](https://img.shields.io/pypi/v/phoenix-channels-python-client.svg)](https://pypi.org/project/phoenix-channels-python-client/)
@@ -264,7 +297,8 @@ Everything after `api_key` is keyword-only:
264
297
  | `protocol_version` | `V2` | Phoenix Channels protocol version |
265
298
  | `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
266
299
  | `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
267
- | `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 |
268
302
  | `join_timeout_s` | `10.0` | Wait for a join reply |
269
303
  | `leave_timeout_s` | `5.0` | Wait for a leave reply |
270
304
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
@@ -315,7 +349,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
315
349
 
316
350
  ### Heartbeats
317
351
 
318
- 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.
319
367
 
320
368
  ## Logging
321
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phoenix-channels-python-client"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "A Python client library for connecting to Phoenix Channels"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -28,6 +28,7 @@ classifiers = [
28
28
  dev = [
29
29
  "pytest",
30
30
  "pytest-asyncio>=1.4",
31
+ "looptime>=0.7,<0.8",
31
32
  "pytest-cov",
32
33
  "pre-commit>=3.0.0",
33
34
  "commitizen>=3.13.0",
@@ -37,6 +38,7 @@ dev = [
37
38
  test = [
38
39
  "pytest",
39
40
  "pytest-asyncio>=1.4",
41
+ "looptime>=0.7,<0.8",
40
42
  ]
41
43
 
42
44
  [tool.ruff.lint]