phoenix-channels-python-client 0.4.0__tar.gz → 0.4.2__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 (48) hide show
  1. {phoenix_channels_python_client-0.4.0/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.2}/PKG-INFO +89 -5
  2. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/README.md +86 -4
  3. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/__init__.py +3 -1
  4. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client.py +24 -0
  5. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client_types.py +4 -1
  6. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/supervisor.py +85 -42
  7. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/topic_runtime.py +181 -45
  8. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/topic_subscription.py +6 -0
  9. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2/phoenix_channels_python_client.egg-info}/PKG-INFO +89 -5
  10. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/SOURCES.txt +7 -0
  11. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
  12. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/pyproject.toml +3 -1
  13. phoenix_channels_python_client-0.4.2/tests/test_cleanup_ownership.py +95 -0
  14. phoenix_channels_python_client-0.4.2/tests/test_heartbeat_timeout.py +717 -0
  15. phoenix_channels_python_client-0.4.2/tests/test_join_installation.py +428 -0
  16. phoenix_channels_python_client-0.4.2/tests/test_join_lifecycle.py +733 -0
  17. phoenix_channels_python_client-0.4.2/tests/test_join_responses.py +203 -0
  18. phoenix_channels_python_client-0.4.2/tests/test_optional_lifecycle.py +582 -0
  19. phoenix_channels_python_client-0.4.2/tests/test_optional_reconnect.py +249 -0
  20. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_shutdown_races.py +14 -6
  21. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_topic_runtime_internals.py +11 -7
  22. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/LICENSE +0 -0
  23. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/MANIFEST.in +0 -0
  24. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/authentication.py +0 -0
  25. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client_state_machine.py +0 -0
  26. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/diagnostics.py +0 -0
  27. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/exceptions.py +0 -0
  28. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/phx_messages.py +0 -0
  29. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/protocol_handler.py +0 -0
  30. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/reconnect_controller.py +0 -0
  31. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  32. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/utils.py +0 -0
  33. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  34. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  35. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/setup.cfg +0 -0
  36. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_cancel_and_wait.py +0 -0
  37. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_channel_recovery.py +0 -0
  38. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_client_state.py +0 -0
  39. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_connection.py +0 -0
  40. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_heartbeat.py +0 -0
  41. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_protocol_handler.py +0 -0
  42. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect.py +0 -0
  43. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect_policy_invariants.py +0 -0
  44. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect_stress.py +0 -0
  45. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_run_forever_signals.py +0 -0
  46. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_security.py +0 -0
  47. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_subscriptions.py +0 -0
  48. {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_supervisor_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.2
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.10,>=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.10,>=0.7; extra == "test"
30
32
  Dynamic: license-file
31
33
 
32
34
  # Phoenix Channels Python Client
@@ -295,18 +297,20 @@ 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 |
302
305
  | `callback_drain_timeout_s` | `2.0` | How long the message being handled (its message handler, then its event handler) may finish before a rejoin cancels it; queued messages are dropped |
303
- | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
306
+ | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
304
307
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
305
308
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
306
309
  | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
307
310
  | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
308
311
 
309
- Exceptions raised in callbacks are logged, not raised.
312
+ Exceptions raised in ordinary message and lifecycle callbacks are logged, not raised.
313
+ Join-response installation failures instead fail the join (see below).
310
314
 
311
315
  ## Connection Lifecycle and Errors
312
316
 
@@ -346,7 +350,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
346
350
 
347
351
  ### Heartbeats
348
352
 
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()`.
353
+ 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.
354
+
355
+ Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
356
+
357
+ ```python
358
+ client = PHXChannelsClient(
359
+ websocket_url, api_key,
360
+ heartbeat_interval_s=30.0,
361
+ heartbeat_dead_threshold_s=60.0,
362
+ )
363
+ ```
364
+
365
+ 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.
366
+
367
+ 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
368
 
351
369
  ## Logging
352
370
 
@@ -402,3 +420,69 @@ headers, frame bodies, user/topic identities, and untrusted server error details
402
420
  Logs retain static lifecycle descriptions, native connection states,
403
421
  and exception types. Application data and caller-visible errors are not changed;
404
422
  applications remain responsible for logs outside this connection-owned boundary.
423
+
424
+ ### Ordered join responses
425
+
426
+ Use the optional keyword-only `on_join_response` hook to install a snapshot from
427
+ initial joins, socket rejoins and same-socket channel recovery:
428
+
429
+ ```python
430
+ state = None
431
+
432
+ def install_snapshot(response):
433
+ global state
434
+ state = validate_snapshot(response)
435
+
436
+ await client.subscribe_to_topic(
437
+ "room:123", on_message, on_join_response=install_snapshot
438
+ )
439
+ ```
440
+
441
+ `JoinResponseCallback` is exported from the package. The hook receives the decoded
442
+ successful `payload.response`; a missing response is `None`. The transport adds no
443
+ application schema or empty snapshot. Installation runs synchronously before
444
+ `is_topic_joined()` becomes true, subscribe completes, or later deltas deliver.
445
+ Subscribe returns `None`. Success admits the subscription; a later disconnect or
446
+ channel failure can already have changed its current readiness when the caller
447
+ resumes.
448
+
449
+ Hooks must be short, nonblocking and return `None`. Do not await, perform I/O,
450
+ wait on locks, defer installation, or change task/client lifecycle inside them.
451
+ Async callables, awaitable results and other non-`None` results fail admission.
452
+ The join timeout bounds reply waiting and cannot preempt synchronous code. Hook
453
+ exceptions (including directly raised `CancelledError`) fail with `PHXTopicError`
454
+ and preserve their cause; arbitrary prior hook effects are not rolled back.
455
+ Initial failure belongs to the caller. An established subscription whose installer
456
+ fails is removed and reported once through `on_topic_lost`.
457
+
458
+ Initial cancellation, timeout and leave remove the exact subscription. Physical
459
+ cleanup remains owned if its waiter is canceled again, and shutdown awaits it.
460
+ Never-established subscriptions are not adopted by automatic recovery. Recorded
461
+ queue loss before a hook-enabled installation fails the attempt transiently;
462
+ steady-state queues still drop their oldest message at capacity. There is no
463
+ replay guarantee. A fresh client must install a fresh snapshot after process loss.
464
+
465
+ Join replies match request refs on both protocols. V2 also filters stale channel
466
+ generations by `join_ref`. V1 cannot reliably fence unsolicited events/lifecycle
467
+ frames from older channels; it does not provide V2 event-generation isolation.
468
+
469
+ ### Optional reconnect participation
470
+
471
+ `subscribe_to_topic(topic, async_callback=None, *, on_join_response=None,
472
+ blocks_reconnect=True) -> None` preserves required recovery by default. Required
473
+ topics reconcile their first socket-rejoin attempt sequentially before
474
+ `on_reconnect`; transient failures continue through existing recovery/backoff.
475
+ The barrier does not wait for eventual success of every required topic.
476
+
477
+ Set `blocks_reconnect=False` for optional subscriptions. After required first
478
+ attempts, their existing per-topic recovery tasks perform an immediate join and
479
+ retry transient failures with the existing backoff. Required event delivery and
480
+ `on_reconnect` do not wait for optional replies, old callback drains or retries.
481
+ This option cannot isolate synchronous code that blocks the event loop. Explicit
482
+ initial subscribe always waits for its own join response and installation.
483
+
484
+ `on_reconnect` conveys no optional readiness or registration-absence guarantee.
485
+ Check `is_topic_joined()` for current readiness and use `on_topic_lost` to replace
486
+ permanently rejected optional subscriptions; do not blindly resubscribe them
487
+ from `on_reconnect`. Leave, shutdown and another socket disconnect cancel/await
488
+ the owned recovery; obsolete generations cannot install or revive a replacement.
@@ -264,18 +264,20 @@ 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 |
271
272
  | `callback_drain_timeout_s` | `2.0` | How long the message being handled (its message handler, then its event handler) may finish before a rejoin cancels it; queued messages are dropped |
272
- | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
273
+ | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
273
274
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
274
275
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
275
276
  | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
276
277
  | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
277
278
 
278
- Exceptions raised in callbacks are logged, not raised.
279
+ Exceptions raised in ordinary message and lifecycle callbacks are logged, not raised.
280
+ Join-response installation failures instead fail the join (see below).
279
281
 
280
282
  ## Connection Lifecycle and Errors
281
283
 
@@ -315,7 +317,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
315
317
 
316
318
  ### Heartbeats
317
319
 
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()`.
320
+ 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.
321
+
322
+ Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
323
+
324
+ ```python
325
+ client = PHXChannelsClient(
326
+ websocket_url, api_key,
327
+ heartbeat_interval_s=30.0,
328
+ heartbeat_dead_threshold_s=60.0,
329
+ )
330
+ ```
331
+
332
+ 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.
333
+
334
+ 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
335
 
320
336
  ## Logging
321
337
 
@@ -371,3 +387,69 @@ headers, frame bodies, user/topic identities, and untrusted server error details
371
387
  Logs retain static lifecycle descriptions, native connection states,
372
388
  and exception types. Application data and caller-visible errors are not changed;
373
389
  applications remain responsible for logs outside this connection-owned boundary.
390
+
391
+ ### Ordered join responses
392
+
393
+ Use the optional keyword-only `on_join_response` hook to install a snapshot from
394
+ initial joins, socket rejoins and same-socket channel recovery:
395
+
396
+ ```python
397
+ state = None
398
+
399
+ def install_snapshot(response):
400
+ global state
401
+ state = validate_snapshot(response)
402
+
403
+ await client.subscribe_to_topic(
404
+ "room:123", on_message, on_join_response=install_snapshot
405
+ )
406
+ ```
407
+
408
+ `JoinResponseCallback` is exported from the package. The hook receives the decoded
409
+ successful `payload.response`; a missing response is `None`. The transport adds no
410
+ application schema or empty snapshot. Installation runs synchronously before
411
+ `is_topic_joined()` becomes true, subscribe completes, or later deltas deliver.
412
+ Subscribe returns `None`. Success admits the subscription; a later disconnect or
413
+ channel failure can already have changed its current readiness when the caller
414
+ resumes.
415
+
416
+ Hooks must be short, nonblocking and return `None`. Do not await, perform I/O,
417
+ wait on locks, defer installation, or change task/client lifecycle inside them.
418
+ Async callables, awaitable results and other non-`None` results fail admission.
419
+ The join timeout bounds reply waiting and cannot preempt synchronous code. Hook
420
+ exceptions (including directly raised `CancelledError`) fail with `PHXTopicError`
421
+ and preserve their cause; arbitrary prior hook effects are not rolled back.
422
+ Initial failure belongs to the caller. An established subscription whose installer
423
+ fails is removed and reported once through `on_topic_lost`.
424
+
425
+ Initial cancellation, timeout and leave remove the exact subscription. Physical
426
+ cleanup remains owned if its waiter is canceled again, and shutdown awaits it.
427
+ Never-established subscriptions are not adopted by automatic recovery. Recorded
428
+ queue loss before a hook-enabled installation fails the attempt transiently;
429
+ steady-state queues still drop their oldest message at capacity. There is no
430
+ replay guarantee. A fresh client must install a fresh snapshot after process loss.
431
+
432
+ Join replies match request refs on both protocols. V2 also filters stale channel
433
+ generations by `join_ref`. V1 cannot reliably fence unsolicited events/lifecycle
434
+ frames from older channels; it does not provide V2 event-generation isolation.
435
+
436
+ ### Optional reconnect participation
437
+
438
+ `subscribe_to_topic(topic, async_callback=None, *, on_join_response=None,
439
+ blocks_reconnect=True) -> None` preserves required recovery by default. Required
440
+ topics reconcile their first socket-rejoin attempt sequentially before
441
+ `on_reconnect`; transient failures continue through existing recovery/backoff.
442
+ The barrier does not wait for eventual success of every required topic.
443
+
444
+ Set `blocks_reconnect=False` for optional subscriptions. After required first
445
+ attempts, their existing per-topic recovery tasks perform an immediate join and
446
+ retry transient failures with the existing backoff. Required event delivery and
447
+ `on_reconnect` do not wait for optional replies, old callback drains or retries.
448
+ This option cannot isolate synchronous code that blocks the event loop. Explicit
449
+ initial subscribe always waits for its own join response and installation.
450
+
451
+ `on_reconnect` conveys no optional readiness or registration-absence guarantee.
452
+ Check `is_topic_joined()` for current readiness and use `on_topic_lost` to replace
453
+ permanently rejected optional subscriptions; do not blindly resubscribe them
454
+ from `on_reconnect`. Leave, shutdown and another socket disconnect cancel/await
455
+ the owned recovery; obsolete generations cannot install or revive a replacement.
@@ -13,18 +13,20 @@ from phoenix_channels_python_client.client import (
13
13
  ReconnectPolicy,
14
14
  TopicLostCallback,
15
15
  )
16
+ from phoenix_channels_python_client.client_types import JoinResponseCallback
16
17
  from phoenix_channels_python_client.protocol_handler import (
17
18
  PhoenixChannelsProtocolVersion,
18
19
  PHXProtocolHandler,
19
20
  )
20
21
  from phoenix_channels_python_client.utils import setup_logging
21
22
 
22
- __version__ = "0.4.0"
23
+ __version__ = "0.4.2"
23
24
  __author__ = "Phoenix Channels Python Client"
24
25
 
25
26
  __all__ = [
26
27
  "DisconnectCallback",
27
28
  "HeartbeatAckCallback",
29
+ "JoinResponseCallback",
28
30
  "PHXChannelsClient",
29
31
  "PHXProtocolHandler",
30
32
  "PhoenixChannelsProtocolVersion",
@@ -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
@@ -178,6 +198,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
178
198
  self._on_heartbeat_ack = on_heartbeat_ack
179
199
  self._on_topic_lost = on_topic_lost
180
200
  self._lifecycle_tasks: set[asyncio.Task[None]] = set()
201
+ self._cleanup_tasks: set[asyncio.Task[None]] = set()
181
202
  self._forced_close_pending = False
182
203
 
183
204
  def redact_diagnostic(self, text: str) -> str:
@@ -195,6 +216,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
195
216
  self._state == ClientState.CLOSED
196
217
  and not self._topic_subscriptions
197
218
  and self.connection is None
219
+ and not self._cleanup_tasks
198
220
  )
199
221
 
200
222
  async def __aenter__(self) -> Self:
@@ -253,6 +275,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
253
275
  await self._unsubscribe_all()
254
276
  # Includes on_topic_lost callbacks still running for topics already gone.
255
277
  await cancel_and_wait(*self._lifecycle_tasks)
278
+ if self._cleanup_tasks:
279
+ await asyncio.gather(*self._cleanup_tasks)
256
280
  await self._stop_supervisor()
257
281
  await self._cleanup_connection()
258
282
  self._connected_event.clear()
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass
4
5
  from enum import StrEnum
5
- from typing import Self
6
+ from typing import Any, Self
6
7
 
7
8
  from pydantic import (
8
9
  BaseModel,
@@ -15,6 +16,8 @@ from pydantic import (
15
16
 
16
17
  from phoenix_channels_python_client.exceptions import PHXConnectionError
17
18
 
19
+ JoinResponseCallback = Callable[[Any], None]
20
+
18
21
 
19
22
  class ClientState(StrEnum):
20
23
  CONNECTING = "connecting"
@@ -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()