phoenix-channels-python-client 0.4.1__tar.gz → 0.4.3__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 (52) hide show
  1. {phoenix_channels_python_client-0.4.1/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.3}/PKG-INFO +101 -5
  2. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/README.md +98 -2
  3. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/__init__.py +19 -1
  4. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/client.py +14 -5
  5. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/client_types.py +60 -4
  6. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/default_decider.py +228 -0
  7. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/reconnect_controller.py +79 -0
  8. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/supervisor.py +49 -78
  9. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/topic_runtime.py +181 -45
  10. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/topic_subscription.py +6 -0
  11. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3/phoenix_channels_python_client.egg-info}/PKG-INFO +101 -5
  12. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client.egg-info/SOURCES.txt +8 -0
  13. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client.egg-info/requires.txt +2 -2
  14. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/pyproject.toml +3 -3
  15. phoenix_channels_python_client-0.4.3/tests/test_cleanup_ownership.py +95 -0
  16. phoenix_channels_python_client-0.4.3/tests/test_join_installation.py +428 -0
  17. phoenix_channels_python_client-0.4.3/tests/test_join_lifecycle.py +733 -0
  18. phoenix_channels_python_client-0.4.3/tests/test_join_responses.py +203 -0
  19. phoenix_channels_python_client-0.4.3/tests/test_optional_lifecycle.py +582 -0
  20. phoenix_channels_python_client-0.4.3/tests/test_optional_reconnect.py +249 -0
  21. phoenix_channels_python_client-0.4.3/tests/test_reconnect_decider.py +506 -0
  22. phoenix_channels_python_client-0.4.3/tests/test_reconnect_policy_invariants.py +319 -0
  23. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_shutdown_races.py +1 -1
  24. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_supervisor_internals.py +8 -31
  25. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_topic_runtime_internals.py +11 -7
  26. phoenix_channels_python_client-0.4.1/phoenix_channels_python_client/reconnect_controller.py +0 -341
  27. phoenix_channels_python_client-0.4.1/tests/test_reconnect_policy_invariants.py +0 -228
  28. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/LICENSE +0 -0
  29. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/MANIFEST.in +0 -0
  30. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/authentication.py +0 -0
  31. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/client_state_machine.py +0 -0
  32. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/diagnostics.py +0 -0
  33. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/exceptions.py +0 -0
  34. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/phx_messages.py +0 -0
  35. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/protocol_handler.py +0 -0
  36. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  37. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/utils.py +0 -0
  38. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  39. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  40. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/setup.cfg +0 -0
  41. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_cancel_and_wait.py +0 -0
  42. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_channel_recovery.py +0 -0
  43. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_client_state.py +0 -0
  44. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_connection.py +0 -0
  45. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_heartbeat.py +0 -0
  46. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_heartbeat_timeout.py +0 -0
  47. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_protocol_handler.py +0 -0
  48. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_reconnect.py +0 -0
  49. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_reconnect_stress.py +0 -0
  50. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_run_forever_signals.py +0 -0
  51. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_security.py +0 -0
  52. {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_subscriptions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.4.1
3
+ Version: 0.4.3
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,7 +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
+ Requires-Dist: looptime<0.10,>=0.7; extra == "dev"
23
23
  Requires-Dist: pytest-cov; extra == "dev"
24
24
  Requires-Dist: pre-commit>=3.0.0; extra == "dev"
25
25
  Requires-Dist: commitizen>=3.13.0; extra == "dev"
@@ -28,7 +28,7 @@ Requires-Dist: ruff; extra == "dev"
28
28
  Provides-Extra: test
29
29
  Requires-Dist: pytest; extra == "test"
30
30
  Requires-Dist: pytest-asyncio>=1.4; extra == "test"
31
- Requires-Dist: looptime<0.8,>=0.7; extra == "test"
31
+ Requires-Dist: looptime<0.10,>=0.7; extra == "test"
32
32
  Dynamic: license-file
33
33
 
34
34
  # Phoenix Channels Python Client
@@ -303,13 +303,15 @@ Everything after `api_key` is keyword-only:
303
303
  | `leave_timeout_s` | `5.0` | Wait for a leave reply |
304
304
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
305
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 |
306
- | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
306
+ | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
307
307
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
308
308
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
309
309
  | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
310
+ | `reconnect_decider` | `None` | Synchronous `(event: ReconnectEvent) -> ReconnectDecision`; replaces the built-in reconnect policy (see [Owning the reconnect policy](#owning-the-reconnect-policy)) |
310
311
  | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
311
312
 
312
- Exceptions raised in callbacks are logged, not raised.
313
+ Exceptions raised in ordinary message and lifecycle callbacks are logged, not raised.
314
+ Join-response installation failures instead fail the join (see below).
313
315
 
314
316
  ## Connection Lifecycle and Errors
315
317
 
@@ -339,6 +341,34 @@ base = ReconnectPolicy()
339
341
  policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
340
342
  ```
341
343
 
344
+ ### Owning the reconnect policy
345
+
346
+ Pass `reconnect_decider` to make every reconnect decision yourself. While `auto_reconnect` is on, the client calls it with:
347
+
348
+ - `Connecting()` before every connection attempt, the first included;
349
+ - `Connected(generation)` when a socket is up, before any topic rejoins on it;
350
+ - `Disconnected(close_code, close_reason)` after every close. A forced close (heartbeat expiry, `close_connection()`) has `close_code=None`, since the received code is unreliable then;
351
+ - `ConnectFailed(error)` after every failed attempt, with the raw exception (a refused handshake is a `websockets` `InvalidStatus` carrying the response).
352
+
353
+ It returns a `ReconnectDecision`:
354
+
355
+ - `ReconnectDecision(should_reconnect=True, delay_s=2.0)` after `Disconnected` or `ConnectFailed` waits exactly 2 s before the next attempt; `delay_s` defaults to `0`. `Connecting` and `Connected` only proceed or stop, and a delay there stops the client with `PHXConnectionError`.
356
+ - `ReconnectDecision(should_reconnect=False)` stops; `run_forever()` returns `None`, and before the first connection `async with` raises `PHXConnectionError`. A stop at `Connected` closes that socket before any rejoin.
357
+ - `ReconnectDecision(should_reconnect=False, terminal_error=PHXConnectionError(...))` stops; `run_forever()` (or `async with`, before the first connection) raises that error.
358
+
359
+ The decider replaces the whole built-in policy above, rapid-disconnect suppression included; the built-in policy is itself a decider over the same events. It runs inline on the client's supervisor task, so keep it fast and never block. The client calls the same decider on every `async with` entry, so reset any per-run state yourself; the built-in one is rebuilt each time. If it raises, the client stops with a `PHXConnectionError` whose `__cause__` is that exception; if it returns anything but a `ReconnectDecision` (an `async def` decider returns a coroutine), the client stops with a `PHXConnectionError` too. It is not asked once `shutdown()` has begun. To end a live connection, call `close_connection()` and stop at the next decision.
360
+
361
+ ```python
362
+ def decide(event: ReconnectEvent) -> ReconnectDecision:
363
+ match event:
364
+ case ConnectFailed(error=InvalidStatus(response=response)) if response.status_code == 403:
365
+ return ReconnectDecision(should_reconnect=False, terminal_error=PHXConnectionError("refused"))
366
+ case Disconnected() | ConnectFailed():
367
+ return ReconnectDecision(should_reconnect=True, delay_s=1.0)
368
+ case _:
369
+ return ReconnectDecision(should_reconnect=True)
370
+ ```
371
+
342
372
  ### Channel recovery
343
373
 
344
374
  A channel can fail while the socket stays up. When its process crashes, the server sends `phx_error`, and the client rejoins that topic on the same socket, waiting the `ReconnectPolicy` backoff (`base_delay_s`, `factor`, `max_delay_s`, with equal jitter) before each attempt. A rejoin that times out is retried the same way, including one after a socket reconnect. A drop of the socket hands the topic to the socket's own rejoin.
@@ -419,3 +449,69 @@ headers, frame bodies, user/topic identities, and untrusted server error details
419
449
  Logs retain static lifecycle descriptions, native connection states,
420
450
  and exception types. Application data and caller-visible errors are not changed;
421
451
  applications remain responsible for logs outside this connection-owned boundary.
452
+
453
+ ### Ordered join responses
454
+
455
+ Use the optional keyword-only `on_join_response` hook to install a snapshot from
456
+ initial joins, socket rejoins and same-socket channel recovery:
457
+
458
+ ```python
459
+ state = None
460
+
461
+ def install_snapshot(response):
462
+ global state
463
+ state = validate_snapshot(response)
464
+
465
+ await client.subscribe_to_topic(
466
+ "room:123", on_message, on_join_response=install_snapshot
467
+ )
468
+ ```
469
+
470
+ `JoinResponseCallback` is exported from the package. The hook receives the decoded
471
+ successful `payload.response`; a missing response is `None`. The transport adds no
472
+ application schema or empty snapshot. Installation runs synchronously before
473
+ `is_topic_joined()` becomes true, subscribe completes, or later deltas deliver.
474
+ Subscribe returns `None`. Success admits the subscription; a later disconnect or
475
+ channel failure can already have changed its current readiness when the caller
476
+ resumes.
477
+
478
+ Hooks must be short, nonblocking and return `None`. Do not await, perform I/O,
479
+ wait on locks, defer installation, or change task/client lifecycle inside them.
480
+ Async callables, awaitable results and other non-`None` results fail admission.
481
+ The join timeout bounds reply waiting and cannot preempt synchronous code. Hook
482
+ exceptions (including directly raised `CancelledError`) fail with `PHXTopicError`
483
+ and preserve their cause; arbitrary prior hook effects are not rolled back.
484
+ Initial failure belongs to the caller. An established subscription whose installer
485
+ fails is removed and reported once through `on_topic_lost`.
486
+
487
+ Initial cancellation, timeout and leave remove the exact subscription. Physical
488
+ cleanup remains owned if its waiter is canceled again, and shutdown awaits it.
489
+ Never-established subscriptions are not adopted by automatic recovery. Recorded
490
+ queue loss before a hook-enabled installation fails the attempt transiently;
491
+ steady-state queues still drop their oldest message at capacity. There is no
492
+ replay guarantee. A fresh client must install a fresh snapshot after process loss.
493
+
494
+ Join replies match request refs on both protocols. V2 also filters stale channel
495
+ generations by `join_ref`. V1 cannot reliably fence unsolicited events/lifecycle
496
+ frames from older channels; it does not provide V2 event-generation isolation.
497
+
498
+ ### Optional reconnect participation
499
+
500
+ `subscribe_to_topic(topic, async_callback=None, *, on_join_response=None,
501
+ blocks_reconnect=True) -> None` preserves required recovery by default. Required
502
+ topics reconcile their first socket-rejoin attempt sequentially before
503
+ `on_reconnect`; transient failures continue through existing recovery/backoff.
504
+ The barrier does not wait for eventual success of every required topic.
505
+
506
+ Set `blocks_reconnect=False` for optional subscriptions. After required first
507
+ attempts, their existing per-topic recovery tasks perform an immediate join and
508
+ retry transient failures with the existing backoff. Required event delivery and
509
+ `on_reconnect` do not wait for optional replies, old callback drains or retries.
510
+ This option cannot isolate synchronous code that blocks the event loop. Explicit
511
+ initial subscribe always waits for its own join response and installation.
512
+
513
+ `on_reconnect` conveys no optional readiness or registration-absence guarantee.
514
+ Check `is_topic_joined()` for current readiness and use `on_topic_lost` to replace
515
+ permanently rejected optional subscriptions; do not blindly resubscribe them
516
+ from `on_reconnect`. Leave, shutdown and another socket disconnect cancel/await
517
+ the owned recovery; obsolete generations cannot install or revive a replacement.
@@ -270,13 +270,15 @@ Everything after `api_key` is keyword-only:
270
270
  | `leave_timeout_s` | `5.0` | Wait for a leave reply |
271
271
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
272
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 |
273
- | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
273
+ | `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
274
274
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
275
275
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
276
276
  | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
277
+ | `reconnect_decider` | `None` | Synchronous `(event: ReconnectEvent) -> ReconnectDecision`; replaces the built-in reconnect policy (see [Owning the reconnect policy](#owning-the-reconnect-policy)) |
277
278
  | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
278
279
 
279
- Exceptions raised in callbacks are logged, not raised.
280
+ Exceptions raised in ordinary message and lifecycle callbacks are logged, not raised.
281
+ Join-response installation failures instead fail the join (see below).
280
282
 
281
283
  ## Connection Lifecycle and Errors
282
284
 
@@ -306,6 +308,34 @@ base = ReconnectPolicy()
306
308
  policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
307
309
  ```
308
310
 
311
+ ### Owning the reconnect policy
312
+
313
+ Pass `reconnect_decider` to make every reconnect decision yourself. While `auto_reconnect` is on, the client calls it with:
314
+
315
+ - `Connecting()` before every connection attempt, the first included;
316
+ - `Connected(generation)` when a socket is up, before any topic rejoins on it;
317
+ - `Disconnected(close_code, close_reason)` after every close. A forced close (heartbeat expiry, `close_connection()`) has `close_code=None`, since the received code is unreliable then;
318
+ - `ConnectFailed(error)` after every failed attempt, with the raw exception (a refused handshake is a `websockets` `InvalidStatus` carrying the response).
319
+
320
+ It returns a `ReconnectDecision`:
321
+
322
+ - `ReconnectDecision(should_reconnect=True, delay_s=2.0)` after `Disconnected` or `ConnectFailed` waits exactly 2 s before the next attempt; `delay_s` defaults to `0`. `Connecting` and `Connected` only proceed or stop, and a delay there stops the client with `PHXConnectionError`.
323
+ - `ReconnectDecision(should_reconnect=False)` stops; `run_forever()` returns `None`, and before the first connection `async with` raises `PHXConnectionError`. A stop at `Connected` closes that socket before any rejoin.
324
+ - `ReconnectDecision(should_reconnect=False, terminal_error=PHXConnectionError(...))` stops; `run_forever()` (or `async with`, before the first connection) raises that error.
325
+
326
+ The decider replaces the whole built-in policy above, rapid-disconnect suppression included; the built-in policy is itself a decider over the same events. It runs inline on the client's supervisor task, so keep it fast and never block. The client calls the same decider on every `async with` entry, so reset any per-run state yourself; the built-in one is rebuilt each time. If it raises, the client stops with a `PHXConnectionError` whose `__cause__` is that exception; if it returns anything but a `ReconnectDecision` (an `async def` decider returns a coroutine), the client stops with a `PHXConnectionError` too. It is not asked once `shutdown()` has begun. To end a live connection, call `close_connection()` and stop at the next decision.
327
+
328
+ ```python
329
+ def decide(event: ReconnectEvent) -> ReconnectDecision:
330
+ match event:
331
+ case ConnectFailed(error=InvalidStatus(response=response)) if response.status_code == 403:
332
+ return ReconnectDecision(should_reconnect=False, terminal_error=PHXConnectionError("refused"))
333
+ case Disconnected() | ConnectFailed():
334
+ return ReconnectDecision(should_reconnect=True, delay_s=1.0)
335
+ case _:
336
+ return ReconnectDecision(should_reconnect=True)
337
+ ```
338
+
309
339
  ### Channel recovery
310
340
 
311
341
  A channel can fail while the socket stays up. When its process crashes, the server sends `phx_error`, and the client rejoins that topic on the same socket, waiting the `ReconnectPolicy` backoff (`base_delay_s`, `factor`, `max_delay_s`, with equal jitter) before each attempt. A rejoin that times out is retried the same way, including one after a socket reconnect. A drop of the socket hands the topic to the socket's own rejoin.
@@ -386,3 +416,69 @@ headers, frame bodies, user/topic identities, and untrusted server error details
386
416
  Logs retain static lifecycle descriptions, native connection states,
387
417
  and exception types. Application data and caller-visible errors are not changed;
388
418
  applications remain responsible for logs outside this connection-owned boundary.
419
+
420
+ ### Ordered join responses
421
+
422
+ Use the optional keyword-only `on_join_response` hook to install a snapshot from
423
+ initial joins, socket rejoins and same-socket channel recovery:
424
+
425
+ ```python
426
+ state = None
427
+
428
+ def install_snapshot(response):
429
+ global state
430
+ state = validate_snapshot(response)
431
+
432
+ await client.subscribe_to_topic(
433
+ "room:123", on_message, on_join_response=install_snapshot
434
+ )
435
+ ```
436
+
437
+ `JoinResponseCallback` is exported from the package. The hook receives the decoded
438
+ successful `payload.response`; a missing response is `None`. The transport adds no
439
+ application schema or empty snapshot. Installation runs synchronously before
440
+ `is_topic_joined()` becomes true, subscribe completes, or later deltas deliver.
441
+ Subscribe returns `None`. Success admits the subscription; a later disconnect or
442
+ channel failure can already have changed its current readiness when the caller
443
+ resumes.
444
+
445
+ Hooks must be short, nonblocking and return `None`. Do not await, perform I/O,
446
+ wait on locks, defer installation, or change task/client lifecycle inside them.
447
+ Async callables, awaitable results and other non-`None` results fail admission.
448
+ The join timeout bounds reply waiting and cannot preempt synchronous code. Hook
449
+ exceptions (including directly raised `CancelledError`) fail with `PHXTopicError`
450
+ and preserve their cause; arbitrary prior hook effects are not rolled back.
451
+ Initial failure belongs to the caller. An established subscription whose installer
452
+ fails is removed and reported once through `on_topic_lost`.
453
+
454
+ Initial cancellation, timeout and leave remove the exact subscription. Physical
455
+ cleanup remains owned if its waiter is canceled again, and shutdown awaits it.
456
+ Never-established subscriptions are not adopted by automatic recovery. Recorded
457
+ queue loss before a hook-enabled installation fails the attempt transiently;
458
+ steady-state queues still drop their oldest message at capacity. There is no
459
+ replay guarantee. A fresh client must install a fresh snapshot after process loss.
460
+
461
+ Join replies match request refs on both protocols. V2 also filters stale channel
462
+ generations by `join_ref`. V1 cannot reliably fence unsolicited events/lifecycle
463
+ frames from older channels; it does not provide V2 event-generation isolation.
464
+
465
+ ### Optional reconnect participation
466
+
467
+ `subscribe_to_topic(topic, async_callback=None, *, on_join_response=None,
468
+ blocks_reconnect=True) -> None` preserves required recovery by default. Required
469
+ topics reconcile their first socket-rejoin attempt sequentially before
470
+ `on_reconnect`; transient failures continue through existing recovery/backoff.
471
+ The barrier does not wait for eventual success of every required topic.
472
+
473
+ Set `blocks_reconnect=False` for optional subscriptions. After required first
474
+ attempts, their existing per-topic recovery tasks perform an immediate join and
475
+ retry transient failures with the existing backoff. Required event delivery and
476
+ `on_reconnect` do not wait for optional replies, old callback drains or retries.
477
+ This option cannot isolate synchronous code that blocks the event loop. Explicit
478
+ initial subscribe always waits for its own join response and installation.
479
+
480
+ `on_reconnect` conveys no optional readiness or registration-absence guarantee.
481
+ Check `is_topic_joined()` for current readiness and use `on_topic_lost` to replace
482
+ permanently rejected optional subscriptions; do not blindly resubscribe them
483
+ from `on_reconnect`. Leave, shutdown and another socket disconnect cancel/await
484
+ the owned recovery; obsolete generations cannot install or revive a replacement.
@@ -13,22 +13,40 @@ from phoenix_channels_python_client.client import (
13
13
  ReconnectPolicy,
14
14
  TopicLostCallback,
15
15
  )
16
+ from phoenix_channels_python_client.client_types import (
17
+ Connected,
18
+ ConnectFailed,
19
+ Connecting,
20
+ Disconnected,
21
+ JoinResponseCallback,
22
+ ReconnectDecider,
23
+ ReconnectDecision,
24
+ ReconnectEvent,
25
+ )
16
26
  from phoenix_channels_python_client.protocol_handler import (
17
27
  PhoenixChannelsProtocolVersion,
18
28
  PHXProtocolHandler,
19
29
  )
20
30
  from phoenix_channels_python_client.utils import setup_logging
21
31
 
22
- __version__ = "0.4.1"
32
+ __version__ = "0.4.3"
23
33
  __author__ = "Phoenix Channels Python Client"
24
34
 
25
35
  __all__ = [
36
+ "ConnectFailed",
37
+ "Connected",
38
+ "Connecting",
26
39
  "DisconnectCallback",
40
+ "Disconnected",
27
41
  "HeartbeatAckCallback",
42
+ "JoinResponseCallback",
28
43
  "PHXChannelsClient",
29
44
  "PHXProtocolHandler",
30
45
  "PhoenixChannelsProtocolVersion",
31
46
  "ReconnectCallback",
47
+ "ReconnectDecider",
48
+ "ReconnectDecision",
49
+ "ReconnectEvent",
32
50
  "ReconnectPolicy",
33
51
  "TopicLostCallback",
34
52
  "setup_logging",
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import logging
5
5
  import math
6
- from collections import deque
7
6
  from collections.abc import Awaitable, Callable
8
7
  from types import TracebackType
9
8
  from typing import Self, cast
@@ -19,7 +18,11 @@ from phoenix_channels_python_client.authentication import (
19
18
  select_authentication_header,
20
19
  )
21
20
  from phoenix_channels_python_client.client_state_machine import transition_client_state
22
- from phoenix_channels_python_client.client_types import ClientState, ReconnectPolicy
21
+ from phoenix_channels_python_client.client_types import (
22
+ ClientState,
23
+ ReconnectDecider,
24
+ ReconnectPolicy,
25
+ )
23
26
  from phoenix_channels_python_client.diagnostics import (
24
27
  ConnectionLogger,
25
28
  diagnostic_secrets,
@@ -114,7 +117,7 @@ def _validate_heartbeat_threshold(
114
117
 
115
118
 
116
119
  class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
117
- def __init__( # noqa: PLR0913 # the public options
120
+ def __init__( # noqa: PLR0913, PLR0915 # the public options
118
121
  self,
119
122
  websocket_url: str,
120
123
  api_key: str,
@@ -133,6 +136,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
133
136
  on_disconnect: DisconnectCallback | None = None,
134
137
  on_heartbeat_ack: HeartbeatAckCallback | None = None,
135
138
  on_topic_lost: TopicLostCallback | None = None,
139
+ reconnect_decider: ReconnectDecider | None = None,
136
140
  additional_headers: dict[str, str] | None = None,
137
141
  ) -> None:
138
142
  self.additional_headers = normalize_headers(additional_headers)
@@ -185,7 +189,6 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
185
189
  self._shutdown_task: asyncio.Task[None] | None = None
186
190
  self._message_routing_task: asyncio.Task[None] | None = None
187
191
  self._initial_connection_future: asyncio.Future[None] | None = None
188
- self._rapid_disconnects: deque[float] = deque()
189
192
  self._terminal_error: Exception | None = None
190
193
  self._heartbeat_dead_threshold_s = heartbeat_dead_threshold_s
191
194
  self._heartbeat_deadline: float | None = None
@@ -197,7 +200,10 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
197
200
  self._on_disconnect = on_disconnect
198
201
  self._on_heartbeat_ack = on_heartbeat_ack
199
202
  self._on_topic_lost = on_topic_lost
203
+ self._reconnect_decider = reconnect_decider
204
+ self._decider = self._new_decider()
200
205
  self._lifecycle_tasks: set[asyncio.Task[None]] = set()
206
+ self._cleanup_tasks: set[asyncio.Task[None]] = set()
201
207
  self._forced_close_pending = False
202
208
 
203
209
  def redact_diagnostic(self, text: str) -> str:
@@ -215,6 +221,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
215
221
  self._state == ClientState.CLOSED
216
222
  and not self._topic_subscriptions
217
223
  and self.connection is None
224
+ and not self._cleanup_tasks
218
225
  )
219
226
 
220
227
  async def __aenter__(self) -> Self:
@@ -224,7 +231,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
224
231
 
225
232
  self._shutdown_event.clear()
226
233
  self._connected_event.clear()
227
- self._rapid_disconnects.clear()
234
+ self._decider = self._new_decider()
228
235
  self._terminal_error = None
229
236
  self._conn_generation = 0
230
237
  self._initial_connection_future = asyncio.get_running_loop().create_future()
@@ -273,6 +280,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
273
280
  await self._unsubscribe_all()
274
281
  # Includes on_topic_lost callbacks still running for topics already gone.
275
282
  await cancel_and_wait(*self._lifecycle_tasks)
283
+ if self._cleanup_tasks:
284
+ await asyncio.gather(*self._cleanup_tasks)
276
285
  await self._stop_supervisor()
277
286
  await self._cleanup_connection()
278
287
  self._connected_event.clear()
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
4
+ from collections.abc import Callable
3
5
  from dataclasses import dataclass
4
6
  from enum import StrEnum
5
- from typing import Self
7
+ from typing import Any, Self
6
8
 
7
9
  from pydantic import (
8
10
  BaseModel,
@@ -15,6 +17,8 @@ from pydantic import (
15
17
 
16
18
  from phoenix_channels_python_client.exceptions import PHXConnectionError
17
19
 
20
+ JoinResponseCallback = Callable[[Any], None]
21
+
18
22
 
19
23
  class ClientState(StrEnum):
20
24
  CONNECTING = "connecting"
@@ -68,9 +72,61 @@ class ReconnectPolicy(BaseModel):
68
72
  return self
69
73
 
70
74
 
71
- @dataclass(frozen=True)
75
+ @dataclass(frozen=True, kw_only=True)
72
76
  class ReconnectDecision:
77
+ """Whether the client goes on, and how long it waits before the next attempt.
78
+
79
+ ``delay_s`` applies after ``Disconnected`` and ``ConnectFailed``; a gate
80
+ (``Connecting``, ``Connected``) only proceeds or stops. A stop with a
81
+ ``terminal_error`` ends the client with that error.
82
+ """
83
+
73
84
  should_reconnect: bool
74
- min_delay_s: float | None = None
75
- max_delay_s: float | None = None
85
+ delay_s: float = 0.0
76
86
  terminal_error: PHXConnectionError | None = None
87
+
88
+ def __post_init__(self) -> None:
89
+ if not math.isfinite(self.delay_s) or self.delay_s < 0:
90
+ raise ValueError("delay_s must be finite and >= 0")
91
+ if not self.should_reconnect and self.delay_s:
92
+ raise ValueError("a stop has no delay_s")
93
+ if self.should_reconnect and self.terminal_error is not None:
94
+ raise ValueError("a terminal_error requires should_reconnect=False")
95
+
96
+
97
+ PROCEED = ReconnectDecision(should_reconnect=True)
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Connecting:
102
+ """A connection attempt is about to start."""
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Connected:
107
+ """The socket for ``generation`` is up; topics rejoin only if this proceeds."""
108
+
109
+ generation: int
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Disconnected:
114
+ """The connection closed. ``close_code`` is ``None`` for a forced close."""
115
+
116
+ close_code: int | None
117
+ close_reason: str
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class ConnectFailed:
122
+ """A connection attempt failed with ``error``, e.g. a refused handshake."""
123
+
124
+ error: Exception
125
+
126
+
127
+ ReconnectEvent = Connecting | Connected | Disconnected | ConnectFailed
128
+
129
+ # Decision points that only proceed or stop; no attempt follows them to delay.
130
+ GATE_EVENTS = (Connecting, Connected)
131
+
132
+ ReconnectDecider = Callable[[ReconnectEvent], ReconnectDecision]