phoenix-channels-python-client 0.2.5__tar.gz → 0.3.0__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 (44) hide show
  1. {phoenix_channels_python_client-0.2.5/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.3.0}/PKG-INFO +30 -14
  2. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/README.md +27 -13
  3. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/__init__.py +5 -4
  4. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/client.py +20 -30
  5. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/client_state_machine.py +1 -3
  6. phoenix_channels_python_client-0.3.0/phoenix_channels_python_client/client_types.py +73 -0
  7. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/exceptions.py +0 -2
  8. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/phx_messages.py +16 -9
  9. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/protocol_handler.py +107 -68
  10. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/reconnect_controller.py +58 -27
  11. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/shutdown_signals.py +1 -0
  12. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/supervisor.py +213 -183
  13. phoenix_channels_python_client-0.3.0/phoenix_channels_python_client/topic_runtime.py +783 -0
  14. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/topic_subscription.py +21 -6
  15. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client/utils.py +14 -15
  16. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0/phoenix_channels_python_client.egg-info}/PKG-INFO +30 -14
  17. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client.egg-info/SOURCES.txt +11 -2
  18. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
  19. phoenix_channels_python_client-0.3.0/pyproject.toml +87 -0
  20. phoenix_channels_python_client-0.3.0/tests/test_cancel_and_wait.py +53 -0
  21. phoenix_channels_python_client-0.3.0/tests/test_channel_recovery.py +350 -0
  22. phoenix_channels_python_client-0.3.0/tests/test_client_state.py +77 -0
  23. phoenix_channels_python_client-0.3.0/tests/test_connection.py +250 -0
  24. phoenix_channels_python_client-0.3.0/tests/test_heartbeat.py +77 -0
  25. phoenix_channels_python_client-0.3.0/tests/test_protocol_handler.py +228 -0
  26. phoenix_channels_python_client-0.3.0/tests/test_reconnect.py +627 -0
  27. phoenix_channels_python_client-0.3.0/tests/test_reconnect_policy_invariants.py +177 -0
  28. phoenix_channels_python_client-0.3.0/tests/test_reconnect_stress.py +185 -0
  29. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/tests/test_run_forever_signals.py +6 -9
  30. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/tests/test_shutdown_races.py +128 -28
  31. phoenix_channels_python_client-0.3.0/tests/test_subscriptions.py +445 -0
  32. phoenix_channels_python_client-0.3.0/tests/test_supervisor_internals.py +178 -0
  33. phoenix_channels_python_client-0.3.0/tests/test_topic_runtime_internals.py +303 -0
  34. phoenix_channels_python_client-0.2.5/phoenix_channels_python_client/client_types.py +0 -102
  35. phoenix_channels_python_client-0.2.5/phoenix_channels_python_client/topic_runtime.py +0 -574
  36. phoenix_channels_python_client-0.2.5/pyproject.toml +0 -52
  37. phoenix_channels_python_client-0.2.5/tests/test_internal_components.py +0 -1117
  38. phoenix_channels_python_client-0.2.5/tests/test_reconnect_policy_invariants.py +0 -124
  39. phoenix_channels_python_client-0.2.5/tests/test_reconnect_stress.py +0 -136
  40. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/LICENSE +0 -0
  41. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/MANIFEST.in +0 -0
  42. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  43. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  44. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.2.5
3
+ Version: 0.3.0
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -14,10 +14,12 @@ Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Python: >=3.11
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
+ Requires-Dist: pydantic>=2.8
17
18
  Requires-Dist: websockets>=14.2
18
19
  Provides-Extra: dev
19
20
  Requires-Dist: pytest; extra == "dev"
20
21
  Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
22
+ Requires-Dist: pytest-cov; extra == "dev"
21
23
  Requires-Dist: pre-commit>=3.0.0; extra == "dev"
22
24
  Requires-Dist: commitizen>=3.13.0; extra == "dev"
23
25
  Requires-Dist: pyrefly; extra == "dev"
@@ -60,21 +62,19 @@ Set up development environment:
60
62
  ```bash
61
63
  git clone https://github.com/band-ai/phoenix-channels-python-client.git
62
64
  cd phoenix-channels-python-client
63
- python3 -m venv venv
64
- source venv/bin/activate # On Windows: venv\Scripts\activate
65
- python3 -m pip install -U pip
66
- pip install -e ".[dev]"
65
+ uv sync --extra dev
67
66
  ```
68
67
 
69
68
  Run tests:
70
69
 
71
70
  ```bash
72
- pytest
71
+ uv run pytest
73
72
  ```
74
73
 
75
74
  ### Dependencies
76
75
 
77
76
  - Python 3.11+
77
+ - `pydantic>=2.8`
78
78
  - `websockets>=14.2`
79
79
 
80
80
  ## Quick Start
@@ -144,11 +144,11 @@ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
144
144
  `phx_error`. Don't use these names for your own events.
145
145
 
146
146
  - The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
147
- - A server `phx_close` or `phx_error` reaches your message handler like any
148
- other message; the client does not rejoin or unsubscribe on its own.
149
- - For an event-specific handler on these, register `PHXEvent.close` or
150
- `PHXEvent.error` from `phoenix_channels_python_client.phx_messages`; the
151
- plain strings don't match.
147
+ - The client handles `phx_error` and `phx_close` itself, and neither reaches
148
+ your handlers: a crashed channel is rejoined, and a channel the server
149
+ closes loses its topic (see [Channel recovery](#channel-recovery)).
150
+ - `add_event_handler` raises `ValueError` for `PHXEvent.error` and
151
+ `PHXEvent.close`. Use `on_topic_lost` and `is_topic_joined()` instead.
152
152
 
153
153
  ## Protocol Versions
154
154
 
@@ -299,10 +299,11 @@ Everything after `api_key` is keyword-only:
299
299
  | `join_timeout_s` | `10.0` | Wait for a join reply |
300
300
  | `leave_timeout_s` | `5.0` | Wait for a leave reply |
301
301
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
302
- | `callback_drain_timeout_s` | `2.0` | How long a running callback may finish before a rejoin cancels it |
302
+ | `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
303
  | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
304
304
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
305
305
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
306
+ | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
306
307
  | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
307
308
 
308
309
  Exceptions raised in callbacks are logged, not raised.
@@ -311,8 +312,8 @@ Exceptions raised in callbacks are logged, not raised.
311
312
 
312
313
  - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
313
314
  - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
314
- - `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription.
315
- - `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on.
315
+ - `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription, and `PHXConnectionError` while disconnected or if the connection drops before the join completes.
316
+ - `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on. It does nothing if the client isn't connected or the connection is already closing.
316
317
  - Exceptions live in `phoenix_channels_python_client.exceptions`.
317
318
 
318
319
  ### Reconnects
@@ -327,6 +328,21 @@ Exceptions raised in callbacks are logged, not raised.
327
328
 
328
329
  Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `PHXConnectionError`. Tune all of this with `ReconnectPolicy`.
329
330
 
331
+ A `ReconnectPolicy` is validated when it is built: an out-of-range or unknown field raises `pydantic.ValidationError`, which is a `ValueError`. To derive one policy from another, validate the merged fields:
332
+
333
+ ```python
334
+ base = ReconnectPolicy()
335
+ policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
336
+ ```
337
+
338
+ ### Channel recovery
339
+
340
+ 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.
341
+
342
+ The client drops the topic and calls `on_topic_lost(topic, error)` once when the server rejects a rejoin, after a crash or a reconnect, closes the channel with `phx_close`, or its message processor fails. It never calls it for `unsubscribe_from_topic()`, `shutdown()` or a failed first `subscribe_to_topic()`, which raises instead. Subscribing to the topic again from the callback works. `shutdown()` cancels the callback, including when the callback itself called `shutdown()`, so code after that call does not run.
343
+
344
+ `get_current_subscriptions()` lists the subscribed topics. `is_topic_joined(topic)` tells whether a topic's channel is joined right now: it's false while the socket is down or a crashed channel waits to rejoin. Unsubscribing a topic whose channel is being recovered completes without waiting for a leave reply.
345
+
330
346
  ### Heartbeats
331
347
 
332
348
  The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
@@ -31,21 +31,19 @@ Set up development environment:
31
31
  ```bash
32
32
  git clone https://github.com/band-ai/phoenix-channels-python-client.git
33
33
  cd phoenix-channels-python-client
34
- python3 -m venv venv
35
- source venv/bin/activate # On Windows: venv\Scripts\activate
36
- python3 -m pip install -U pip
37
- pip install -e ".[dev]"
34
+ uv sync --extra dev
38
35
  ```
39
36
 
40
37
  Run tests:
41
38
 
42
39
  ```bash
43
- pytest
40
+ uv run pytest
44
41
  ```
45
42
 
46
43
  ### Dependencies
47
44
 
48
45
  - Python 3.11+
46
+ - `pydantic>=2.8`
49
47
  - `websockets>=14.2`
50
48
 
51
49
  ## Quick Start
@@ -115,11 +113,11 @@ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
115
113
  `phx_error`. Don't use these names for your own events.
116
114
 
117
115
  - The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
118
- - A server `phx_close` or `phx_error` reaches your message handler like any
119
- other message; the client does not rejoin or unsubscribe on its own.
120
- - For an event-specific handler on these, register `PHXEvent.close` or
121
- `PHXEvent.error` from `phoenix_channels_python_client.phx_messages`; the
122
- plain strings don't match.
116
+ - The client handles `phx_error` and `phx_close` itself, and neither reaches
117
+ your handlers: a crashed channel is rejoined, and a channel the server
118
+ closes loses its topic (see [Channel recovery](#channel-recovery)).
119
+ - `add_event_handler` raises `ValueError` for `PHXEvent.error` and
120
+ `PHXEvent.close`. Use `on_topic_lost` and `is_topic_joined()` instead.
123
121
 
124
122
  ## Protocol Versions
125
123
 
@@ -270,10 +268,11 @@ Everything after `api_key` is keyword-only:
270
268
  | `join_timeout_s` | `10.0` | Wait for a join reply |
271
269
  | `leave_timeout_s` | `5.0` | Wait for a leave reply |
272
270
  | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
273
- | `callback_drain_timeout_s` | `2.0` | How long a running callback may finish before a rejoin cancels it |
271
+ | `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 |
274
272
  | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
275
273
  | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
276
274
  | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
275
+ | `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
277
276
  | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
278
277
 
279
278
  Exceptions raised in callbacks are logged, not raised.
@@ -282,8 +281,8 @@ Exceptions raised in callbacks are logged, not raised.
282
281
 
283
282
  - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
284
283
  - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
285
- - `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription.
286
- - `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on.
284
+ - `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription, and `PHXConnectionError` while disconnected or if the connection drops before the join completes.
285
+ - `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on. It does nothing if the client isn't connected or the connection is already closing.
287
286
  - Exceptions live in `phoenix_channels_python_client.exceptions`.
288
287
 
289
288
  ### Reconnects
@@ -298,6 +297,21 @@ Exceptions raised in callbacks are logged, not raised.
298
297
 
299
298
  Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `PHXConnectionError`. Tune all of this with `ReconnectPolicy`.
300
299
 
300
+ A `ReconnectPolicy` is validated when it is built: an out-of-range or unknown field raises `pydantic.ValidationError`, which is a `ValueError`. To derive one policy from another, validate the merged fields:
301
+
302
+ ```python
303
+ base = ReconnectPolicy()
304
+ policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
305
+ ```
306
+
307
+ ### Channel recovery
308
+
309
+ 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.
310
+
311
+ The client drops the topic and calls `on_topic_lost(topic, error)` once when the server rejects a rejoin, after a crash or a reconnect, closes the channel with `phx_close`, or its message processor fails. It never calls it for `unsubscribe_from_topic()`, `shutdown()` or a failed first `subscribe_to_topic()`, which raises instead. Subscribing to the topic again from the callback works. `shutdown()` cancels the callback, including when the callback itself called `shutdown()`, so code after that call does not run.
312
+
313
+ `get_current_subscriptions()` lists the subscribed topics. `is_topic_joined(topic)` tells whether a topic's channel is joined right now: it's false while the socket is down or a crashed channel waits to rejoin. Unsubscribing a topic whose channel is being recovered completes without waiting for a leave reply.
314
+
301
315
  ### Heartbeats
302
316
 
303
317
  The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
@@ -1,5 +1,4 @@
1
- """
2
- Phoenix Channels Python Client
1
+ """Phoenix Channels Python Client.
3
2
 
4
3
  A Python client library for connecting to Phoenix Channels.
5
4
  """
@@ -12,14 +11,15 @@ from phoenix_channels_python_client.client import (
12
11
  PHXChannelsClient,
13
12
  ReconnectCallback,
14
13
  ReconnectPolicy,
14
+ TopicLostCallback,
15
15
  )
16
16
  from phoenix_channels_python_client.protocol_handler import (
17
- PHXProtocolHandler,
18
17
  PhoenixChannelsProtocolVersion,
18
+ PHXProtocolHandler,
19
19
  )
20
20
  from phoenix_channels_python_client.utils import setup_logging
21
21
 
22
- __version__ = "0.2.5"
22
+ __version__ = "0.3.0"
23
23
  __author__ = "Phoenix Channels Python Client"
24
24
 
25
25
  __all__ = [
@@ -30,5 +30,6 @@ __all__ = [
30
30
  "PhoenixChannelsProtocolVersion",
31
31
  "ReconnectCallback",
32
32
  "ReconnectPolicy",
33
+ "TopicLostCallback",
33
34
  "setup_logging",
34
35
  ]
@@ -5,21 +5,18 @@ import logging
5
5
  from collections import deque
6
6
  from collections.abc import Awaitable, Callable
7
7
  from types import TracebackType
8
+ from typing import Self
8
9
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
9
10
 
10
11
  from websockets import ClientConnection
11
12
 
12
13
  from phoenix_channels_python_client.client_state_machine import transition_client_state
13
- from phoenix_channels_python_client.client_types import (
14
- ClientState,
15
- ReconnectPolicy,
16
- reconnect_policy_is_invalid,
17
- validate_reconnect_policy,
18
- )
14
+ from phoenix_channels_python_client.client_types import ClientState, ReconnectPolicy
19
15
  from phoenix_channels_python_client.exceptions import PHXConnectionError
20
16
  from phoenix_channels_python_client.protocol_handler import (
21
- PHXProtocolHandler,
17
+ DEFAULT_PROTOCOL_VERSION,
22
18
  PhoenixChannelsProtocolVersion,
19
+ PHXProtocolHandler,
23
20
  )
24
21
  from phoenix_channels_python_client.reconnect_controller import ReconnectControllerMixin
25
22
  from phoenix_channels_python_client.supervisor import SupervisorMixin
@@ -35,6 +32,7 @@ DisconnectCallback = Callable[[Exception | None], Awaitable[None]]
35
32
  # on the message-routing hot path, so blocking work here stalls heartbeat
36
33
  # sends and message routing for every connection.
37
34
  HeartbeatAckCallback = Callable[[], None]
35
+ TopicLostCallback = Callable[[str, Exception], Awaitable[None]]
38
36
 
39
37
 
40
38
  def _build_channel_socket_urls(
@@ -74,12 +72,13 @@ def _build_channel_socket_urls(
74
72
 
75
73
 
76
74
  class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
77
- def __init__(
75
+ def __init__( # noqa: PLR0913 # the public options
78
76
  self,
79
77
  websocket_url: str,
80
78
  api_key: str,
81
79
  *,
82
- protocol_version: PhoenixChannelsProtocolVersion = PhoenixChannelsProtocolVersion.V2,
80
+ protocol_version: PhoenixChannelsProtocolVersion
81
+ | str = DEFAULT_PROTOCOL_VERSION,
83
82
  auto_reconnect: bool = True,
84
83
  reconnect_policy: ReconnectPolicy | None = None,
85
84
  join_timeout_s: float = 10.0,
@@ -90,8 +89,9 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
90
89
  on_reconnect: ReconnectCallback | None = None,
91
90
  on_disconnect: DisconnectCallback | None = None,
92
91
  on_heartbeat_ack: HeartbeatAckCallback | None = None,
92
+ on_topic_lost: TopicLostCallback | None = None,
93
93
  additional_headers: dict[str, str] | None = None,
94
- ):
94
+ ) -> None:
95
95
  self.logger = logger
96
96
 
97
97
  if heartbeat_interval_s is not None and heartbeat_interval_s <= 0:
@@ -105,20 +105,12 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
105
105
  raise ValueError("max_topic_queue_size must be > 0")
106
106
  if callback_drain_timeout_s <= 0:
107
107
  raise ValueError("callback_drain_timeout_s must be > 0")
108
- try:
109
- validate_reconnect_policy(reconnect_policy or ReconnectPolicy())
110
- except ValueError as exc:
111
- raise ValueError("Invalid reconnect policy configuration") from exc
112
-
113
- vsn = (
114
- "2.0.0"
115
- if protocol_version == PhoenixChannelsProtocolVersion.V2
116
- else "1.0.0"
117
- )
108
+
109
+ self._protocol_handler = PHXProtocolHandler(protocol_version)
118
110
  connect_url, redacted_url = _build_channel_socket_urls(
119
111
  websocket_url=websocket_url,
120
112
  api_key=api_key,
121
- vsn=vsn,
113
+ vsn=self._protocol_handler.protocol_version.vsn,
122
114
  )
123
115
  self.channel_socket_url = connect_url
124
116
  self.channel_socket_url_redacted = redacted_url
@@ -138,7 +130,6 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
138
130
 
139
131
  self.connection: ClientConnection | None = None
140
132
  self._topic_subscriptions: dict[str, TopicSubscription] = {}
141
- self._protocol_handler = PHXProtocolHandler(protocol_version)
142
133
  self._ref_counter = 0
143
134
  self._state = ClientState.CLOSED
144
135
  self._topics_lock = asyncio.Lock()
@@ -157,6 +148,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
157
148
  self._on_reconnect = on_reconnect
158
149
  self._on_disconnect = on_disconnect
159
150
  self._on_heartbeat_ack = on_heartbeat_ack
151
+ self._on_topic_lost = on_topic_lost
152
+ self._lifecycle_tasks: set[asyncio.Task[None]] = set()
160
153
  self._forced_close_pending = False
161
154
 
162
155
  @property
@@ -172,11 +165,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
172
165
  and self.connection is None
173
166
  )
174
167
 
175
- @staticmethod
176
- def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
177
- return reconnect_policy_is_invalid(policy)
178
-
179
- async def __aenter__(self) -> PHXChannelsClient:
168
+ async def __aenter__(self) -> Self:
180
169
  self.logger.debug("Entering PHXChannelsClient context")
181
170
  if self._state != ClientState.CLOSED or self._active_shutdown is not None:
182
171
  raise PHXConnectionError("Client is already running")
@@ -185,6 +174,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
185
174
  self._connected_event.clear()
186
175
  self._rapid_disconnects.clear()
187
176
  self._terminal_error = None
177
+ self._conn_generation = 0
188
178
  self._initial_connection_future = asyncio.get_running_loop().create_future()
189
179
  self._transition_state(ClientState.CONNECTING)
190
180
 
@@ -225,6 +215,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
225
215
  self._shutdown_event.set()
226
216
 
227
217
  await self._unsubscribe_all()
218
+ # Includes on_topic_lost callbacks still running for topics already gone.
219
+ await cancel_and_wait(*self._lifecycle_tasks)
228
220
  await self._stop_supervisor()
229
221
  await self._cleanup_connection()
230
222
  self._connected_event.clear()
@@ -258,7 +250,5 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
258
250
  return
259
251
 
260
252
  transitioned_state = transition_client_state(self._state, new_state)
261
- self.logger.debug(
262
- "Client state transition: %s -> %s", self._state.value, new_state.value
263
- )
253
+ self.logger.debug("Client state transition: %s -> %s", self._state, new_state)
264
254
  self._state = transitioned_state
@@ -31,8 +31,6 @@ def transition_client_state(
31
31
  }
32
32
 
33
33
  if new_state not in allowed_transitions[current]:
34
- raise RuntimeError(
35
- f"Invalid state transition {current.value} -> {new_state.value}"
36
- )
34
+ raise RuntimeError(f"Invalid state transition {current} -> {new_state}")
37
35
 
38
36
  return new_state
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from typing import Self
6
+
7
+ from pydantic import (
8
+ BaseModel,
9
+ ConfigDict,
10
+ NonNegativeFloat,
11
+ NonNegativeInt,
12
+ PositiveFloat,
13
+ model_validator,
14
+ )
15
+
16
+ from phoenix_channels_python_client.exceptions import PHXConnectionError
17
+
18
+
19
+ class ClientState(StrEnum):
20
+ CONNECTING = "connecting"
21
+ CONNECTED = "connected"
22
+ RECONNECTING = "reconnecting"
23
+ SHUTTING_DOWN = "shutting_down"
24
+ CLOSED = "closed"
25
+
26
+
27
+ # Each (low, high) pair of fields where high must not be below low.
28
+ _ORDERED_POLICY_FIELDS = (
29
+ ("service_restart_min_delay_s", "service_restart_max_delay_s"),
30
+ ("try_again_later_min_delay_s", "try_again_later_max_delay_s"),
31
+ ("rapid_cooldown_base_s", "rapid_cooldown_max_s"),
32
+ ("rapid_hold_down_jitter_low_ratio", "rapid_hold_down_jitter_high_ratio"),
33
+ )
34
+
35
+
36
+ class ReconnectPolicy(BaseModel):
37
+ model_config = ConfigDict(frozen=True, extra="forbid")
38
+
39
+ base_delay_s: NonNegativeFloat = 0.5
40
+ factor: PositiveFloat = 2.0
41
+ max_delay_s: NonNegativeFloat = 30.0
42
+ stable_reset_s: PositiveFloat = 60.0
43
+ reconnect_on_normal_close: bool = False
44
+ policy_violation_is_terminal: bool = True
45
+ service_restart_min_delay_s: NonNegativeFloat = 1.0
46
+ service_restart_max_delay_s: NonNegativeFloat = 5.0
47
+ try_again_later_min_delay_s: NonNegativeFloat = 30.0
48
+ try_again_later_max_delay_s: NonNegativeFloat = 60.0
49
+ rapid_disconnect_uptime_s: NonNegativeFloat = 5.0
50
+ rapid_window_s: PositiveFloat = 60.0
51
+ rapid_first_min_delay_s: NonNegativeFloat = 2.0
52
+ rapid_second_min_delay_s: NonNegativeFloat = 10.0
53
+ rapid_cooldown_base_s: NonNegativeFloat = 60.0
54
+ rapid_cooldown_step_s: NonNegativeFloat = 30.0
55
+ rapid_cooldown_max_s: NonNegativeFloat = 300.0
56
+ rapid_suppress_disconnect_count: NonNegativeInt = 10
57
+ rapid_hold_down_jitter_low_ratio: NonNegativeFloat = 0.25
58
+ rapid_hold_down_jitter_high_ratio: NonNegativeFloat = 1.0
59
+
60
+ @model_validator(mode="after")
61
+ def _check_ordering(self) -> Self:
62
+ for low, high in _ORDERED_POLICY_FIELDS:
63
+ if getattr(self, high) < getattr(self, low):
64
+ raise ValueError(f"{high} must be >= {low}")
65
+ return self
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ReconnectDecision:
70
+ should_reconnect: bool
71
+ min_delay_s: float | None = None
72
+ max_delay_s: float | None = None
73
+ terminal_error: PHXConnectionError | None = None
@@ -8,5 +8,3 @@ class PHXTopicError(PHXClientError):
8
8
 
9
9
  class PHXConnectionError(PHXClientError):
10
10
  """Raised when there's an error connecting to the Phoenix WebSocket server."""
11
-
12
- pass
@@ -1,10 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
4
3
  from enum import Enum, unique
5
4
  from functools import cached_property
6
- from typing import Any, NewType
5
+ from typing import Annotated, Any, NewType
7
6
 
7
+ from pydantic import BaseModel, ConfigDict, Field
8
8
 
9
9
  PHOENIX_TOPIC = "phoenix"
10
10
 
@@ -21,16 +21,25 @@ class PHXEvent(Enum):
21
21
  return self.value
22
22
 
23
23
 
24
+ # The client handles these itself: a crash rejoins, a close loses the topic.
25
+ CLIENT_LIFECYCLE_EVENTS = frozenset({PHXEvent.error, PHXEvent.close})
26
+
24
27
  UserEvent = NewType("UserEvent", str)
25
28
  # Compatibility alias for existing imports and call sites.
26
29
  Event = UserEvent
30
+ # Not a PHXEvent member: that would reroute make_message and change a public enum.
31
+ HEARTBEAT_EVENT = Event("heartbeat")
27
32
  ChannelEvent = PHXEvent | UserEvent
28
33
 
34
+ # Servers may send a ref as a JSON number; the client matches refs as strings.
35
+ Ref = Annotated[str | None, Field(coerce_numbers_to_str=True)]
36
+
37
+
38
+ class BasePHXMessage(BaseModel):
39
+ model_config = ConfigDict(frozen=True)
29
40
 
30
- @dataclass(frozen=True)
31
- class BasePHXMessage:
32
41
  topic: str
33
- ref: str | None
42
+ ref: Ref
34
43
  payload: dict[str, Any]
35
44
 
36
45
  @cached_property
@@ -41,16 +50,14 @@ class BasePHXMessage:
41
50
  return subtopic
42
51
 
43
52
 
44
- @dataclass(frozen=True)
45
53
  class PHXMessage(BasePHXMessage):
46
54
  event: Event
47
- join_ref: str | None = None
55
+ join_ref: Ref = None
48
56
 
49
57
 
50
- @dataclass(frozen=True)
51
58
  class PHXEventMessage(BasePHXMessage):
52
59
  event: PHXEvent
53
- join_ref: str | None = None
60
+ join_ref: Ref = None
54
61
 
55
62
 
56
63
  ChannelMessage = PHXMessage | PHXEventMessage