phoenix-channels-python-client 0.2.2__py3-none-any.whl → 0.2.4__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.
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  from phoenix_channels_python_client.client import (
10
10
  DisconnectCallback,
11
+ HeartbeatAckCallback,
11
12
  PHXChannelsClient,
12
13
  ReconnectCallback,
13
14
  ReconnectPolicy,
@@ -18,11 +19,12 @@ from phoenix_channels_python_client.protocol_handler import (
18
19
  )
19
20
  from phoenix_channels_python_client.utils import setup_logging
20
21
 
21
- __version__ = "0.2.2"
22
+ __version__ = "0.2.4"
22
23
  __author__ = "Phoenix Channels Python Client"
23
24
 
24
25
  __all__ = [
25
26
  "DisconnectCallback",
27
+ "HeartbeatAckCallback",
26
28
  "PHXChannelsClient",
27
29
  "PHXProtocolHandler",
28
30
  "PhoenixChannelsProtocolVersion",
@@ -30,6 +30,10 @@ logger = logging.getLogger(__name__)
30
30
 
31
31
  ReconnectCallback = Callable[[], Awaitable[None]]
32
32
  DisconnectCallback = Callable[[Exception | None], Awaitable[None]]
33
+ # Must be synchronous and fast: unlike the callbacks above, this runs inline
34
+ # on the message-routing hot path, so blocking work here stalls heartbeat
35
+ # sends and message routing for every connection.
36
+ HeartbeatAckCallback = Callable[[], None]
33
37
 
34
38
 
35
39
  def _build_channel_socket_urls(
@@ -84,6 +88,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
84
88
  heartbeat_interval_s: float | None = 30.0,
85
89
  on_reconnect: ReconnectCallback | None = None,
86
90
  on_disconnect: DisconnectCallback | None = None,
91
+ on_heartbeat_ack: HeartbeatAckCallback | None = None,
87
92
  additional_headers: dict[str, str] | None = None,
88
93
  ):
89
94
  self.logger = logger
@@ -149,6 +154,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
149
154
  self._pending_heartbeat_ref: str | None = None
150
155
  self._on_reconnect = on_reconnect
151
156
  self._on_disconnect = on_disconnect
157
+ self._on_heartbeat_ack = on_heartbeat_ack
158
+ self._forced_close_pending = False
152
159
 
153
160
  @staticmethod
154
161
  def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
@@ -6,6 +6,9 @@ from functools import cached_property
6
6
  from typing import Any, NewType
7
7
 
8
8
 
9
+ PHOENIX_TOPIC = "phoenix"
10
+
11
+
9
12
  @unique
10
13
  class PHXEvent(Enum):
11
14
  close = "phx_close"
@@ -8,7 +8,11 @@ from enum import Enum
8
8
 
9
9
  from websockets import ClientConnection
10
10
 
11
- from phoenix_channels_python_client.phx_messages import ChannelMessage, Event
11
+ from phoenix_channels_python_client.phx_messages import (
12
+ PHOENIX_TOPIC,
13
+ ChannelMessage,
14
+ Event,
15
+ )
12
16
  from phoenix_channels_python_client.topic_subscription import TopicSubscription
13
17
  from phoenix_channels_python_client.utils import make_message
14
18
 
@@ -155,7 +159,7 @@ class PHXProtocolHandler:
155
159
  self.logger.debug("Processing message - %s", phx_message)
156
160
  topic = phx_message.topic
157
161
 
158
- if topic == "phoenix":
162
+ if topic == PHOENIX_TOPIC:
159
163
  if on_heartbeat_response is not None:
160
164
  on_heartbeat_response(phx_message)
161
165
  continue
@@ -7,6 +7,7 @@ from collections import deque
7
7
 
8
8
  from websockets import ClientConnection
9
9
  from websockets.exceptions import ConnectionClosed
10
+ from websockets.frames import CloseCode
10
11
 
11
12
  from phoenix_channels_python_client.client_types import (
12
13
  ReconnectDecision,
@@ -159,7 +160,7 @@ class ReconnectControllerMixin:
159
160
  return decision
160
161
 
161
162
  if (
162
- close_code in {1000, 1001}
163
+ close_code in {CloseCode.NORMAL_CLOSURE, CloseCode.GOING_AWAY}
163
164
  and not self.reconnect_policy.reconnect_on_normal_close
164
165
  ):
165
166
  decision = ReconnectDecision(should_reconnect=False)
@@ -176,12 +177,15 @@ class ReconnectControllerMixin:
176
177
  )
177
178
  return decision
178
179
 
179
- if close_code == 1008 and self.reconnect_policy.policy_violation_is_terminal:
180
+ if (
181
+ close_code == CloseCode.POLICY_VIOLATION
182
+ and self.reconnect_policy.policy_violation_is_terminal
183
+ ):
180
184
  reason = close_reason or "policy violation"
181
185
  decision = ReconnectDecision(
182
186
  should_reconnect=False,
183
187
  terminal_error=PHXConnectionError(
184
- f"Reconnect disabled due to terminal close code 1008 ({reason})"
188
+ f"Reconnect disabled due to terminal close code {CloseCode.POLICY_VIOLATION} ({reason})"
185
189
  ),
186
190
  )
187
191
  logger.debug(
@@ -199,7 +203,7 @@ class ReconnectControllerMixin:
199
203
  )
200
204
  return decision
201
205
 
202
- if close_code == 1012:
206
+ if close_code == CloseCode.SERVICE_RESTART:
203
207
  decision = ReconnectDecision(
204
208
  should_reconnect=True,
205
209
  min_delay_s=self.reconnect_policy.service_restart_min_delay_s,
@@ -222,7 +226,7 @@ class ReconnectControllerMixin:
222
226
  )
223
227
  return decision
224
228
 
225
- if close_code == 1013:
229
+ if close_code == CloseCode.TRY_AGAIN_LATER:
226
230
  decision = ReconnectDecision(
227
231
  should_reconnect=True,
228
232
  min_delay_s=self.reconnect_policy.try_again_later_min_delay_s,
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import inspect
4
5
  import logging
5
6
  import signal
6
7
  from collections import deque
@@ -17,11 +18,31 @@ from phoenix_channels_python_client.client_types import (
17
18
  ReconnectPolicy,
18
19
  )
19
20
  from phoenix_channels_python_client.exceptions import PHXConnectionError
20
- from phoenix_channels_python_client.phx_messages import ChannelMessage, Event
21
+ from phoenix_channels_python_client.phx_messages import (
22
+ PHOENIX_TOPIC,
23
+ ChannelMessage,
24
+ Event,
25
+ )
21
26
  from phoenix_channels_python_client.protocol_handler import PHXProtocolHandler
22
27
  from phoenix_channels_python_client.topic_subscription import TopicSubscription
23
28
  from phoenix_channels_python_client.utils import make_message
24
29
 
30
+ # RFC 6455 §7.4.2 private-use range (4000-4999, unregistrable); outside
31
+ # _classify_disconnect's specially-handled codes, so a forced close
32
+ # reconnects like any other unclassified disconnect.
33
+ _FORCED_CLOSE_CODE = 4000
34
+
35
+ # RFC 6455 §5.5.1: control frames (including Close) cap at 125 bytes total;
36
+ # 2 of those bytes are the close code, leaving 123 for the reason.
37
+ _MAX_CLOSE_REASON_BYTES = 123
38
+
39
+
40
+ def _truncate_close_reason(reason: str) -> str:
41
+ encoded = reason.encode("utf-8")
42
+ if len(encoded) <= _MAX_CLOSE_REASON_BYTES:
43
+ return reason
44
+ return encoded[:_MAX_CLOSE_REASON_BYTES].decode("utf-8", errors="ignore")
45
+
25
46
 
26
47
  class _SupervisorRuntimeDeps(Protocol):
27
48
  async def _rejoin_topics(self, generation: int) -> None: ...
@@ -65,6 +86,8 @@ class SupervisorMixin:
65
86
  _pending_heartbeat_ref: str | None
66
87
  _on_reconnect: Callable[[], Awaitable[None]] | None
67
88
  _on_disconnect: Callable[[Exception | None], Awaitable[None]] | None
89
+ _on_heartbeat_ack: Callable[[], None] | None
90
+ _forced_close_pending: bool
68
91
 
69
92
  async def _start_processing(
70
93
  self, connection: ClientConnection, conn_generation: int
@@ -80,6 +103,37 @@ class SupervisorMixin:
80
103
  if message.ref is not None and message.ref == self._pending_heartbeat_ref:
81
104
  self.logger.debug("Heartbeat acknowledged (ref=%s)", message.ref)
82
105
  self._pending_heartbeat_ref = None
106
+ if self._on_heartbeat_ack is not None:
107
+ try:
108
+ result = self._on_heartbeat_ack()
109
+ if inspect.iscoroutine(result):
110
+ result.close()
111
+ raise TypeError(
112
+ "on_heartbeat_ack must be synchronous; an async "
113
+ "function was passed and its body never ran"
114
+ )
115
+ except Exception:
116
+ self.logger.exception("Error in on_heartbeat_ack callback")
117
+
118
+ async def _invoke_callback_safely(
119
+ self, label: str, callback: Callable[..., Awaitable[None]], *args: object
120
+ ) -> None:
121
+ try:
122
+ await callback(*args)
123
+ except Exception:
124
+ self.logger.exception("Error in %s callback", label)
125
+
126
+ async def close_connection(self, reason: str) -> None:
127
+ """Force-close the current connection so the supervisor's own
128
+ disconnect handling decides whether to reconnect. No-op if not
129
+ currently connected."""
130
+ connection = self.connection
131
+ if connection is None:
132
+ return
133
+ reason = _truncate_close_reason(reason)
134
+ self.logger.info("Forcing connection close: %s", reason)
135
+ self._forced_close_pending = True
136
+ await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
83
137
 
84
138
  async def _heartbeat_loop(self, connection: ClientConnection) -> None:
85
139
  if self._heartbeat_interval_s is None:
@@ -105,7 +159,7 @@ class SupervisorMixin:
105
159
  self._pending_heartbeat_ref = ref
106
160
 
107
161
  heartbeat_message = make_message(
108
- topic="phoenix",
162
+ topic=PHOENIX_TOPIC,
109
163
  event=Event("heartbeat"),
110
164
  payload={},
111
165
  ref=ref,
@@ -208,10 +262,9 @@ class SupervisorMixin:
208
262
  self._initial_connection_future.set_result(None)
209
263
 
210
264
  if generation > 1 and self._on_reconnect is not None:
211
- try:
212
- await self._on_reconnect()
213
- except Exception:
214
- self.logger.exception("Error in on_reconnect callback")
265
+ await self._invoke_callback_safely(
266
+ "on_reconnect", self._on_reconnect
267
+ )
215
268
 
216
269
  try:
217
270
  await self._message_routing_task
@@ -234,14 +287,15 @@ class SupervisorMixin:
234
287
  connection=connection,
235
288
  routing_error=routing_error,
236
289
  )
290
+ forced_close = self._forced_close_pending
291
+ self._forced_close_pending = False
237
292
 
238
293
  await self._cleanup_connection()
239
294
 
240
295
  if self._on_disconnect is not None:
241
- try:
242
- await self._on_disconnect(routing_error)
243
- except Exception:
244
- self.logger.exception("Error in on_disconnect callback")
296
+ await self._invoke_callback_safely(
297
+ "on_disconnect", self._on_disconnect, routing_error
298
+ )
245
299
 
246
300
  if self._shutdown_event.is_set():
247
301
  break
@@ -249,7 +303,17 @@ class SupervisorMixin:
249
303
  if not self.auto_reconnect:
250
304
  break
251
305
 
252
- decision = runtime_deps._classify_disconnect(close_code, close_reason)
306
+ if forced_close:
307
+ # The remote's echoed close code isn't guaranteed to match
308
+ # what we sent (websockets' close_code reflects the code
309
+ # *received*, not sent), so classification would be
310
+ # unreliable here; a forced close always reconnects like
311
+ # any other unclassified disconnect.
312
+ decision = ReconnectDecision(should_reconnect=True)
313
+ else:
314
+ decision = runtime_deps._classify_disconnect(
315
+ close_code, close_reason
316
+ )
253
317
  if decision.terminal_error is not None:
254
318
  self._terminal_error = decision.terminal_error
255
319
  self.logger.error("%s", self._terminal_error)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -42,8 +42,8 @@ A modern, async Python client library for connecting to [Phoenix Channels](https
42
42
  Install from source:
43
43
 
44
44
  ```bash
45
- git clone https://github.com/your-org/phoenix_channels_python_client.git
46
- cd phoenix_channels_python_client
45
+ git clone https://github.com/band-ai/phoenix-channels-python-client.git
46
+ cd phoenix-channels-python-client
47
47
  python3 -m venv venv
48
48
  source venv/bin/activate # On Windows: venv\Scripts\activate
49
49
  pip install .
@@ -54,8 +54,8 @@ pip install .
54
54
  Set up development environment:
55
55
 
56
56
  ```bash
57
- git clone https://github.com/your-org/phoenix_channels_python_client.git
58
- cd phoenix_channels_python_client
57
+ git clone https://github.com/band-ai/phoenix-channels-python-client.git
58
+ cd phoenix-channels-python-client
59
59
  python3 -m venv venv
60
60
  source venv/bin/activate # On Windows: venv\Scripts\activate
61
61
  python3 -m pip install -U pip
@@ -0,0 +1,17 @@
1
+ phoenix_channels_python_client/__init__.py,sha256=EVdevvtteX6B9wQewjgbn_f_S3H-w4YkIRpeXK0oKZE,791
2
+ phoenix_channels_python_client/client.py,sha256=EAy1vVC3JInfNk6RYvbE6d8ZC5YmPNho1q474IrptHQ,9546
3
+ phoenix_channels_python_client/client_state_machine.py,sha256=Exr5wjYB0VXpCq1-tGHN4VraNman1M1vWN073rRFZFo,1124
4
+ phoenix_channels_python_client/client_types.py,sha256=Gyyf72zDpAZtGcnbXJMW6Xj6PksdOM6bh-BXjlN6U5Y,3889
5
+ phoenix_channels_python_client/exceptions.py,sha256=1cpnD_SO3En-yZKS47QSlLjLnK-29gOBcBVHoBQVMkg,227
6
+ phoenix_channels_python_client/phx_messages.py,sha256=lvtyECL_CwUlP_yom57f0U_OpGJsOqETy7KEtdu_yf4,1214
7
+ phoenix_channels_python_client/protocol_handler.py,sha256=ohnZN0Z3GmM0xiRAhLD5n4XtbL37A_HvEgs5b_uBmg8,8141
8
+ phoenix_channels_python_client/reconnect_controller.py,sha256=R924_f59fQN7jvQXFfQxKb6LDBYxheSPszOFETpZ5iI,11025
9
+ phoenix_channels_python_client/supervisor.py,sha256=OU1ywX1dAAvw1cNNtFaO6CqdI_G_J7oRevx-rzpQrUI,18133
10
+ phoenix_channels_python_client/topic_runtime.py,sha256=IkZ6tXxRP5Svc8vkuAUo-ic5K_pfsHmYHmabRAHmYnQ,21829
11
+ phoenix_channels_python_client/topic_subscription.py,sha256=J0wcCsfP9laQ7DxT-6e7OdhQrK_t1zu3n3gcnuedvFI,1916
12
+ phoenix_channels_python_client/utils.py,sha256=MdxW2tq5MTCE7RQhhajwChMipSHXJyopBwq5ORo0Vao,1616
13
+ phoenix_channels_python_client-0.2.4.dist-info/licenses/LICENSE,sha256=zwqkIClV_fMWOC0Auvd0DYqZMLL0-hIqeKd6tygE39g,1064
14
+ phoenix_channels_python_client-0.2.4.dist-info/METADATA,sha256=gPYMjC6UbmF8CSHUh7U5VV6WZ7sJZe1GS_ZbsC5nUTM,9157
15
+ phoenix_channels_python_client-0.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ phoenix_channels_python_client-0.2.4.dist-info/top_level.txt,sha256=ZYXMGCOeev4cyNU9XvNGHKt1wOgVkFHaS9TlwDa4eDU,31
17
+ phoenix_channels_python_client-0.2.4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,17 +0,0 @@
1
- phoenix_channels_python_client/__init__.py,sha256=LCv6fVM36IZ2Vf60SlKFZnmcT8vkJS7wgvfTitS-sac,737
2
- phoenix_channels_python_client/client.py,sha256=1h-34mci3HB7UtBI_3JmZVep_-9coXxCNS7NnWfOUZk,9148
3
- phoenix_channels_python_client/client_state_machine.py,sha256=Exr5wjYB0VXpCq1-tGHN4VraNman1M1vWN073rRFZFo,1124
4
- phoenix_channels_python_client/client_types.py,sha256=Gyyf72zDpAZtGcnbXJMW6Xj6PksdOM6bh-BXjlN6U5Y,3889
5
- phoenix_channels_python_client/exceptions.py,sha256=1cpnD_SO3En-yZKS47QSlLjLnK-29gOBcBVHoBQVMkg,227
6
- phoenix_channels_python_client/phx_messages.py,sha256=EiJX0KbcpJBtNj0r3j4zdN9qCb7fzJB87TtjuFYGDwQ,1186
7
- phoenix_channels_python_client/protocol_handler.py,sha256=6nuespMcWWhVQwea8W3eANr_Ea6BcZBqNj1ui1WvDuE,8105
8
- phoenix_channels_python_client/reconnect_controller.py,sha256=v2YXx5qVlxxQw8jUy-5kvrkMDR_slRRVqhJr7GrZsUk,10825
9
- phoenix_channels_python_client/supervisor.py,sha256=l4-awiJiSVU4qBhCJeDkKnNnAw_1dLWlrE0y_2e74Zw,15464
10
- phoenix_channels_python_client/topic_runtime.py,sha256=IkZ6tXxRP5Svc8vkuAUo-ic5K_pfsHmYHmabRAHmYnQ,21829
11
- phoenix_channels_python_client/topic_subscription.py,sha256=J0wcCsfP9laQ7DxT-6e7OdhQrK_t1zu3n3gcnuedvFI,1916
12
- phoenix_channels_python_client/utils.py,sha256=MdxW2tq5MTCE7RQhhajwChMipSHXJyopBwq5ORo0Vao,1616
13
- phoenix_channels_python_client-0.2.2.dist-info/licenses/LICENSE,sha256=zwqkIClV_fMWOC0Auvd0DYqZMLL0-hIqeKd6tygE39g,1064
14
- phoenix_channels_python_client-0.2.2.dist-info/METADATA,sha256=uKqLijxsab8Y8Q962t4lxi8u6Rsv3HzXoz-6QG543xE,9159
15
- phoenix_channels_python_client-0.2.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
16
- phoenix_channels_python_client-0.2.2.dist-info/top_level.txt,sha256=ZYXMGCOeev4cyNU9XvNGHKt1wOgVkFHaS9TlwDa4eDU,31
17
- phoenix_channels_python_client-0.2.2.dist-info/RECORD,,