phoenix-channels-python-client 0.2.4__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
@@ -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.4"
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,26 +5,24 @@ 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
26
23
  from phoenix_channels_python_client.topic_runtime import TopicRuntimeMixin
27
24
  from phoenix_channels_python_client.topic_subscription import TopicSubscription
25
+ from phoenix_channels_python_client.utils import cancel_and_wait
28
26
 
29
27
  logger = logging.getLogger(__name__)
30
28
 
@@ -34,6 +32,7 @@ DisconnectCallback = Callable[[Exception | None], Awaitable[None]]
34
32
  # on the message-routing hot path, so blocking work here stalls heartbeat
35
33
  # sends and message routing for every connection.
36
34
  HeartbeatAckCallback = Callable[[], None]
35
+ TopicLostCallback = Callable[[str, Exception], Awaitable[None]]
37
36
 
38
37
 
39
38
  def _build_channel_socket_urls(
@@ -73,12 +72,13 @@ def _build_channel_socket_urls(
73
72
 
74
73
 
75
74
  class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
76
- def __init__(
75
+ def __init__( # noqa: PLR0913 # the public options
77
76
  self,
78
77
  websocket_url: str,
79
78
  api_key: str,
80
79
  *,
81
- protocol_version: PhoenixChannelsProtocolVersion = PhoenixChannelsProtocolVersion.V2,
80
+ protocol_version: PhoenixChannelsProtocolVersion
81
+ | str = DEFAULT_PROTOCOL_VERSION,
82
82
  auto_reconnect: bool = True,
83
83
  reconnect_policy: ReconnectPolicy | None = None,
84
84
  join_timeout_s: float = 10.0,
@@ -89,8 +89,9 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
89
89
  on_reconnect: ReconnectCallback | None = None,
90
90
  on_disconnect: DisconnectCallback | None = None,
91
91
  on_heartbeat_ack: HeartbeatAckCallback | None = None,
92
+ on_topic_lost: TopicLostCallback | None = None,
92
93
  additional_headers: dict[str, str] | None = None,
93
- ):
94
+ ) -> None:
94
95
  self.logger = logger
95
96
 
96
97
  if heartbeat_interval_s is not None and heartbeat_interval_s <= 0:
@@ -104,20 +105,12 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
104
105
  raise ValueError("max_topic_queue_size must be > 0")
105
106
  if callback_drain_timeout_s <= 0:
106
107
  raise ValueError("callback_drain_timeout_s must be > 0")
107
- try:
108
- validate_reconnect_policy(reconnect_policy or ReconnectPolicy())
109
- except ValueError as exc:
110
- raise ValueError("Invalid reconnect policy configuration") from exc
111
-
112
- vsn = (
113
- "2.0.0"
114
- if protocol_version == PhoenixChannelsProtocolVersion.V2
115
- else "1.0.0"
116
- )
108
+
109
+ self._protocol_handler = PHXProtocolHandler(protocol_version)
117
110
  connect_url, redacted_url = _build_channel_socket_urls(
118
111
  websocket_url=websocket_url,
119
112
  api_key=api_key,
120
- vsn=vsn,
113
+ vsn=self._protocol_handler.protocol_version.vsn,
121
114
  )
122
115
  self.channel_socket_url = connect_url
123
116
  self.channel_socket_url_redacted = redacted_url
@@ -137,7 +130,6 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
137
130
 
138
131
  self.connection: ClientConnection | None = None
139
132
  self._topic_subscriptions: dict[str, TopicSubscription] = {}
140
- self._protocol_handler = PHXProtocolHandler(protocol_version)
141
133
  self._ref_counter = 0
142
134
  self._state = ClientState.CLOSED
143
135
  self._topics_lock = asyncio.Lock()
@@ -145,6 +137,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
145
137
  self._connected_event = asyncio.Event()
146
138
  self._conn_generation = 0
147
139
  self._supervisor_task: asyncio.Task[None] | None = None
140
+ self._shutdown_task: asyncio.Task[None] | None = None
148
141
  self._message_routing_task: asyncio.Task[None] | None = None
149
142
  self._initial_connection_future: asyncio.Future[None] | None = None
150
143
  self._rapid_disconnects: deque[float] = deque()
@@ -155,21 +148,33 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
155
148
  self._on_reconnect = on_reconnect
156
149
  self._on_disconnect = on_disconnect
157
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()
158
153
  self._forced_close_pending = False
159
154
 
160
- @staticmethod
161
- def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
162
- return reconnect_policy_is_invalid(policy)
155
+ @property
156
+ def _active_shutdown(self) -> asyncio.Task[None] | None:
157
+ task = self._shutdown_task
158
+ return task if task is not None and not task.done() else None
163
159
 
164
- async def __aenter__(self) -> PHXChannelsClient:
160
+ @property
161
+ def _is_fully_closed(self) -> bool:
162
+ return (
163
+ self._state == ClientState.CLOSED
164
+ and not self._topic_subscriptions
165
+ and self.connection is None
166
+ )
167
+
168
+ async def __aenter__(self) -> Self:
165
169
  self.logger.debug("Entering PHXChannelsClient context")
166
- if self._state != ClientState.CLOSED:
170
+ if self._state != ClientState.CLOSED or self._active_shutdown is not None:
167
171
  raise PHXConnectionError("Client is already running")
168
172
 
169
173
  self._shutdown_event.clear()
170
174
  self._connected_event.clear()
171
175
  self._rapid_disconnects.clear()
172
176
  self._terminal_error = None
177
+ self._conn_generation = 0
173
178
  self._initial_connection_future = asyncio.get_running_loop().create_future()
174
179
  self._transition_state(ClientState.CONNECTING)
175
180
 
@@ -192,57 +197,58 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
192
197
  self.logger.debug("Leaving PHXChannelsClient context")
193
198
  await self.shutdown("Leaving PHXChannelsClient context")
194
199
 
195
- async def shutdown(
196
- self,
197
- reason: str,
198
- ) -> None:
199
- if (
200
- self._state == ClientState.CLOSED
201
- and not self._topic_subscriptions
202
- and self.connection is None
203
- ):
204
- return
205
-
200
+ async def shutdown(self, reason: str) -> None:
201
+ task = self._active_shutdown
202
+ if task is None:
203
+ if self._is_fully_closed:
204
+ return
205
+ task = self._shutdown_task = asyncio.create_task(self._shutdown(reason))
206
+ # Every caller waits for the same shutdown, and one caller's
207
+ # cancellation never aborts it.
208
+ await asyncio.shield(task)
209
+
210
+ async def _shutdown(self, reason: str) -> None:
206
211
  self.logger.info("Event loop shutting down! reason=%s", reason)
207
212
 
208
213
  if self._state not in (ClientState.SHUTTING_DOWN, ClientState.CLOSED):
209
214
  self._transition_state(ClientState.SHUTTING_DOWN)
210
-
211
215
  self._shutdown_event.set()
212
216
 
213
- topics_to_unsubscribe = list(self._topic_subscriptions.keys())
214
- if topics_to_unsubscribe:
215
- unsubscribe_tasks = [
216
- self.unsubscribe_from_topic(topic, _allow_disconnected=True)
217
- for topic in topics_to_unsubscribe
218
- ]
219
- results = await asyncio.gather(*unsubscribe_tasks, return_exceptions=True)
220
-
221
- for topic, result in zip(topics_to_unsubscribe, results, strict=False):
222
- if isinstance(result, Exception):
223
- self.logger.warning(
224
- "Failed to unsubscribe from topic %s during shutdown: %s",
225
- topic,
226
- result,
227
- )
228
-
229
- if self._supervisor_task and not self._supervisor_task.done():
230
- self._supervisor_task.cancel()
231
- try:
232
- await self._supervisor_task
233
- except asyncio.CancelledError:
234
- self.logger.debug("Supervisor task cancelled during shutdown")
235
-
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)
220
+ await self._stop_supervisor()
236
221
  await self._cleanup_connection()
237
222
  self._connected_event.clear()
238
223
  self._transition_state(ClientState.CLOSED)
239
224
 
225
+ async def _unsubscribe_all(self) -> None:
226
+ topics = list(self._topic_subscriptions)
227
+ results = await asyncio.gather(
228
+ *(
229
+ self.unsubscribe_from_topic(topic, _allow_disconnected=True)
230
+ for topic in topics
231
+ ),
232
+ return_exceptions=True,
233
+ )
234
+ for topic, result in zip(topics, results, strict=True):
235
+ if isinstance(result, Exception):
236
+ self.logger.warning(
237
+ "Failed to unsubscribe from topic %s during shutdown: %s",
238
+ topic,
239
+ result,
240
+ )
241
+
242
+ async def _stop_supervisor(self) -> None:
243
+ # Cleared, so run_forever() after the stop reports the client isn't running.
244
+ supervisor, self._supervisor_task = self._supervisor_task, None
245
+ if supervisor is not None and not supervisor.done():
246
+ await cancel_and_wait(supervisor)
247
+
240
248
  def _transition_state(self, new_state: ClientState) -> None:
241
249
  if self._state == new_state:
242
250
  return
243
251
 
244
252
  transitioned_state = transition_client_state(self._state, new_state)
245
- self.logger.debug(
246
- "Client state transition: %s -> %s", self._state.value, new_state.value
247
- )
253
+ self.logger.debug("Client state transition: %s -> %s", self._state, new_state)
248
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
@@ -1,12 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from enum import Enum
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
+ )
5
15
 
6
16
  from phoenix_channels_python_client.exceptions import PHXConnectionError
7
17
 
8
18
 
9
- class ClientState(Enum):
19
+ class ClientState(StrEnum):
10
20
  CONNECTING = "connecting"
11
21
  CONNECTED = "connected"
12
22
  RECONNECTING = "reconnecting"
@@ -14,28 +24,45 @@ class ClientState(Enum):
14
24
  CLOSED = "closed"
15
25
 
16
26
 
17
- @dataclass(frozen=True)
18
- class ReconnectPolicy:
19
- base_delay_s: float = 0.5
20
- factor: float = 2.0
21
- max_delay_s: float = 30.0
22
- stable_reset_s: float = 60.0
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
23
43
  reconnect_on_normal_close: bool = False
24
44
  policy_violation_is_terminal: bool = True
25
- service_restart_min_delay_s: float = 1.0
26
- service_restart_max_delay_s: float = 5.0
27
- try_again_later_min_delay_s: float = 30.0
28
- try_again_later_max_delay_s: float = 60.0
29
- rapid_disconnect_uptime_s: float = 5.0
30
- rapid_window_s: float = 60.0
31
- rapid_first_min_delay_s: float = 2.0
32
- rapid_second_min_delay_s: float = 10.0
33
- rapid_cooldown_base_s: float = 60.0
34
- rapid_cooldown_step_s: float = 30.0
35
- rapid_cooldown_max_s: float = 300.0
36
- rapid_suppress_disconnect_count: int = 10
37
- rapid_hold_down_jitter_low_ratio: float = 0.25
38
- rapid_hold_down_jitter_high_ratio: float = 1.0
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
39
66
 
40
67
 
41
68
  @dataclass(frozen=True)
@@ -44,59 +71,3 @@ class ReconnectDecision:
44
71
  min_delay_s: float | None = None
45
72
  max_delay_s: float | None = None
46
73
  terminal_error: PHXConnectionError | None = None
47
-
48
-
49
- def validate_reconnect_policy(policy: ReconnectPolicy) -> None:
50
- if policy.base_delay_s < 0:
51
- raise ValueError("base_delay_s must be >= 0")
52
- if policy.factor <= 0:
53
- raise ValueError("factor must be > 0")
54
- if policy.max_delay_s < 0:
55
- raise ValueError("max_delay_s must be >= 0")
56
- if policy.stable_reset_s <= 0:
57
- raise ValueError("stable_reset_s must be > 0")
58
- if policy.service_restart_min_delay_s < 0:
59
- raise ValueError("service_restart_min_delay_s must be >= 0")
60
- if policy.service_restart_max_delay_s < policy.service_restart_min_delay_s:
61
- raise ValueError(
62
- "service_restart_max_delay_s must be >= service_restart_min_delay_s"
63
- )
64
- if policy.try_again_later_min_delay_s < 0:
65
- raise ValueError("try_again_later_min_delay_s must be >= 0")
66
- if policy.try_again_later_max_delay_s < policy.try_again_later_min_delay_s:
67
- raise ValueError(
68
- "try_again_later_max_delay_s must be >= try_again_later_min_delay_s"
69
- )
70
- if policy.rapid_disconnect_uptime_s < 0:
71
- raise ValueError("rapid_disconnect_uptime_s must be >= 0")
72
- if policy.rapid_window_s <= 0:
73
- raise ValueError("rapid_window_s must be > 0")
74
- if policy.rapid_first_min_delay_s < 0:
75
- raise ValueError("rapid_first_min_delay_s must be >= 0")
76
- if policy.rapid_second_min_delay_s < 0:
77
- raise ValueError("rapid_second_min_delay_s must be >= 0")
78
- if policy.rapid_cooldown_base_s < 0:
79
- raise ValueError("rapid_cooldown_base_s must be >= 0")
80
- if policy.rapid_cooldown_step_s < 0:
81
- raise ValueError("rapid_cooldown_step_s must be >= 0")
82
- if policy.rapid_cooldown_max_s < policy.rapid_cooldown_base_s:
83
- raise ValueError("rapid_cooldown_max_s must be >= rapid_cooldown_base_s")
84
- if policy.rapid_suppress_disconnect_count < 0:
85
- raise ValueError("rapid_suppress_disconnect_count must be >= 0")
86
- if policy.rapid_hold_down_jitter_low_ratio < 0:
87
- raise ValueError("rapid_hold_down_jitter_low_ratio must be >= 0")
88
- if (
89
- policy.rapid_hold_down_jitter_high_ratio
90
- < policy.rapid_hold_down_jitter_low_ratio
91
- ):
92
- raise ValueError(
93
- "rapid_hold_down_jitter_high_ratio must be >= rapid_hold_down_jitter_low_ratio"
94
- )
95
-
96
-
97
- def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
98
- try:
99
- validate_reconnect_policy(policy)
100
- except ValueError:
101
- return True
102
- return False
@@ -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