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.
- {phoenix_channels_python_client-0.4.0/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.2}/PKG-INFO +89 -5
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/README.md +86 -4
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/__init__.py +3 -1
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client.py +24 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client_types.py +4 -1
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/supervisor.py +85 -42
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/topic_runtime.py +181 -45
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/topic_subscription.py +6 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2/phoenix_channels_python_client.egg-info}/PKG-INFO +89 -5
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/SOURCES.txt +7 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/pyproject.toml +3 -1
- phoenix_channels_python_client-0.4.2/tests/test_cleanup_ownership.py +95 -0
- phoenix_channels_python_client-0.4.2/tests/test_heartbeat_timeout.py +717 -0
- phoenix_channels_python_client-0.4.2/tests/test_join_installation.py +428 -0
- phoenix_channels_python_client-0.4.2/tests/test_join_lifecycle.py +733 -0
- phoenix_channels_python_client-0.4.2/tests/test_join_responses.py +203 -0
- phoenix_channels_python_client-0.4.2/tests/test_optional_lifecycle.py +582 -0
- phoenix_channels_python_client-0.4.2/tests/test_optional_reconnect.py +249 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_shutdown_races.py +14 -6
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_topic_runtime_internals.py +11 -7
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/LICENSE +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/authentication.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/diagnostics.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/phx_messages.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/reconnect_controller.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/shutdown_signals.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/phoenix_channels_python_client/utils.py +0 -0
- {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
- {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
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_channel_recovery.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_client_state.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_connection.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_heartbeat.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect_policy_invariants.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_reconnect_stress.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_run_forever_signals.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_security.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.2}/tests/test_subscriptions.py +0 -0
- {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.
|
|
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
|
|
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`.
|
|
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
|
|
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`.
|
|
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.
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
|
|
181
|
+
except (Exception, asyncio.CancelledError):
|
|
182
|
+
connection.transport.abort()
|
|
183
|
+
raise
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
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()
|