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.
- {phoenix_channels_python_client-0.4.1/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.3}/PKG-INFO +101 -5
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/README.md +98 -2
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/__init__.py +19 -1
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/client.py +14 -5
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/client_types.py +60 -4
- phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/default_decider.py +228 -0
- phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/reconnect_controller.py +79 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/supervisor.py +49 -78
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/topic_runtime.py +181 -45
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/topic_subscription.py +6 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3/phoenix_channels_python_client.egg-info}/PKG-INFO +101 -5
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client.egg-info/SOURCES.txt +8 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client.egg-info/requires.txt +2 -2
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/pyproject.toml +3 -3
- phoenix_channels_python_client-0.4.3/tests/test_cleanup_ownership.py +95 -0
- phoenix_channels_python_client-0.4.3/tests/test_join_installation.py +428 -0
- phoenix_channels_python_client-0.4.3/tests/test_join_lifecycle.py +733 -0
- phoenix_channels_python_client-0.4.3/tests/test_join_responses.py +203 -0
- phoenix_channels_python_client-0.4.3/tests/test_optional_lifecycle.py +582 -0
- phoenix_channels_python_client-0.4.3/tests/test_optional_reconnect.py +249 -0
- phoenix_channels_python_client-0.4.3/tests/test_reconnect_decider.py +506 -0
- phoenix_channels_python_client-0.4.3/tests/test_reconnect_policy_invariants.py +319 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_shutdown_races.py +1 -1
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_supervisor_internals.py +8 -31
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_topic_runtime_internals.py +11 -7
- phoenix_channels_python_client-0.4.1/phoenix_channels_python_client/reconnect_controller.py +0 -341
- phoenix_channels_python_client-0.4.1/tests/test_reconnect_policy_invariants.py +0 -228
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/LICENSE +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/authentication.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/diagnostics.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/phx_messages.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/shutdown_signals.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/phoenix_channels_python_client/utils.py +0 -0
- {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
- {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
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_channel_recovery.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_client_state.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_connection.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_heartbeat.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_heartbeat_timeout.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_reconnect.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_reconnect_stress.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_run_forever_signals.py +0 -0
- {phoenix_channels_python_client-0.4.1 → phoenix_channels_python_client-0.4.3}/tests/test_security.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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]
|