phoenix-channels-python-client 0.2.2__tar.gz → 0.2.4__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 (26) hide show
  1. {phoenix_channels_python_client-0.2.2/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.2.4}/PKG-INFO +5 -5
  2. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/README.md +4 -4
  3. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/__init__.py +3 -1
  4. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/client.py +7 -0
  5. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/phx_messages.py +3 -0
  6. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/protocol_handler.py +6 -2
  7. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/reconnect_controller.py +9 -5
  8. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/supervisor.py +75 -11
  9. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4/phoenix_channels_python_client.egg-info}/PKG-INFO +5 -5
  10. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/pyproject.toml +1 -1
  11. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/tests/test_internal_components.py +160 -1
  12. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/LICENSE +0 -0
  13. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/MANIFEST.in +0 -0
  14. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/client_state_machine.py +0 -0
  15. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/client_types.py +0 -0
  16. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/exceptions.py +0 -0
  17. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/topic_runtime.py +0 -0
  18. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/topic_subscription.py +0 -0
  19. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/utils.py +0 -0
  20. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client.egg-info/SOURCES.txt +0 -0
  21. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  22. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  23. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  24. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/setup.cfg +0 -0
  25. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/tests/test_reconnect_policy_invariants.py +0 -0
  26. {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/tests/test_reconnect_stress.py +0 -0
@@ -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
@@ -13,8 +13,8 @@ A modern, async Python client library for connecting to [Phoenix Channels](https
13
13
  Install from source:
14
14
 
15
15
  ```bash
16
- git clone https://github.com/your-org/phoenix_channels_python_client.git
17
- cd phoenix_channels_python_client
16
+ git clone https://github.com/band-ai/phoenix-channels-python-client.git
17
+ cd phoenix-channels-python-client
18
18
  python3 -m venv venv
19
19
  source venv/bin/activate # On Windows: venv\Scripts\activate
20
20
  pip install .
@@ -25,8 +25,8 @@ pip install .
25
25
  Set up development environment:
26
26
 
27
27
  ```bash
28
- git clone https://github.com/your-org/phoenix_channels_python_client.git
29
- cd phoenix_channels_python_client
28
+ git clone https://github.com/band-ai/phoenix-channels-python-client.git
29
+ cd phoenix-channels-python-client
30
30
  python3 -m venv venv
31
31
  source venv/bin/activate # On Windows: venv\Scripts\activate
32
32
  python3 -m pip install -U pip
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phoenix-channels-python-client"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "A Python client library for connecting to Phoenix Channels"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -70,9 +70,11 @@ class _FakeSocket:
70
70
 
71
71
  def __post_init__(self) -> None:
72
72
  self.closed = False
73
+ self.close_calls: list[tuple[int | None, str]] = []
73
74
 
74
- async def close(self) -> None:
75
+ async def close(self, code: int | None = None, reason: str = "") -> None:
75
76
  self.closed = True
77
+ self.close_calls.append((code, reason))
76
78
  if self.close_raises:
77
79
  raise RuntimeError("close boom")
78
80
 
@@ -150,6 +152,8 @@ class _SupervisorHarness(SupervisorMixin):
150
152
  self._ref_counter = 0
151
153
  self._on_reconnect = None
152
154
  self._on_disconnect = None
155
+ self._on_heartbeat_ack = None
156
+ self._forced_close_pending = False
153
157
 
154
158
  self.transition_history: list[ClientState] = []
155
159
  self.disconnect_uptimes: list[float] = []
@@ -977,3 +981,158 @@ async def test_supervisor_callback_exception_does_not_crash_loop(
977
981
 
978
982
  await harness._supervisor_loop()
979
983
  assert harness.connection is None
984
+
985
+
986
+ @pytest.mark.asyncio
987
+ async def test_handle_heartbeat_response_fires_on_heartbeat_ack_callback() -> None:
988
+ harness = _SupervisorHarness()
989
+ harness._pending_heartbeat_ref = "5"
990
+
991
+ ack_count = 0
992
+
993
+ def on_heartbeat_ack() -> None:
994
+ nonlocal ack_count
995
+ ack_count += 1
996
+
997
+ harness._on_heartbeat_ack = on_heartbeat_ack
998
+
999
+ harness._handle_heartbeat_response(
1000
+ make_message(topic="phoenix", event=PHXEvent.reply, payload={}, ref="5")
1001
+ )
1002
+
1003
+ assert ack_count == 1
1004
+ assert harness._pending_heartbeat_ref is None
1005
+
1006
+
1007
+ @pytest.mark.asyncio
1008
+ async def test_handle_heartbeat_response_ignores_mismatched_ref() -> None:
1009
+ harness = _SupervisorHarness()
1010
+ harness._pending_heartbeat_ref = "5"
1011
+
1012
+ ack_count = 0
1013
+
1014
+ def on_heartbeat_ack() -> None:
1015
+ nonlocal ack_count
1016
+ ack_count += 1
1017
+
1018
+ harness._on_heartbeat_ack = on_heartbeat_ack
1019
+
1020
+ harness._handle_heartbeat_response(
1021
+ make_message(topic="phoenix", event=PHXEvent.reply, payload={}, ref="not-5")
1022
+ )
1023
+
1024
+ assert ack_count == 0
1025
+ assert harness._pending_heartbeat_ref == "5"
1026
+
1027
+
1028
+ @pytest.mark.asyncio
1029
+ async def test_handle_heartbeat_response_callback_exception_does_not_propagate() -> (
1030
+ None
1031
+ ):
1032
+ harness = _SupervisorHarness()
1033
+ harness._pending_heartbeat_ref = "5"
1034
+
1035
+ def bad_heartbeat_ack() -> None:
1036
+ raise ValueError("callback boom")
1037
+
1038
+ harness._on_heartbeat_ack = bad_heartbeat_ack
1039
+
1040
+ harness._handle_heartbeat_response(
1041
+ make_message(topic="phoenix", event=PHXEvent.reply, payload={}, ref="5")
1042
+ )
1043
+
1044
+ assert harness._pending_heartbeat_ref is None
1045
+
1046
+
1047
+ @pytest.mark.asyncio
1048
+ async def test_handle_heartbeat_response_rejects_async_callback() -> None:
1049
+ harness = _SupervisorHarness()
1050
+ harness._pending_heartbeat_ref = "5"
1051
+
1052
+ ran: list[bool] = []
1053
+
1054
+ async def async_on_heartbeat_ack() -> None:
1055
+ ran.append(True)
1056
+
1057
+ harness._on_heartbeat_ack = async_on_heartbeat_ack # type: ignore[assignment]
1058
+
1059
+ harness._handle_heartbeat_response(
1060
+ make_message(topic="phoenix", event=PHXEvent.reply, payload={}, ref="5")
1061
+ )
1062
+
1063
+ assert harness._pending_heartbeat_ref is None
1064
+ assert ran == []
1065
+
1066
+
1067
+ @pytest.mark.asyncio
1068
+ async def test_close_connection_closes_current_connection_for_reconnect() -> None:
1069
+ harness = _SupervisorHarness()
1070
+ socket = _FakeSocket()
1071
+ harness.connection = cast(ClientConnection, socket)
1072
+
1073
+ await harness.close_connection("dead threshold exceeded")
1074
+
1075
+ assert socket.closed
1076
+ assert socket.close_calls == [(4000, "dead threshold exceeded")]
1077
+
1078
+
1079
+ @pytest.mark.asyncio
1080
+ async def test_close_connection_is_noop_when_not_connected() -> None:
1081
+ harness = _SupervisorHarness()
1082
+ harness.connection = None
1083
+
1084
+ await harness.close_connection("dead threshold exceeded")
1085
+
1086
+ assert harness.connection is None
1087
+
1088
+
1089
+ @pytest.mark.asyncio
1090
+ async def test_close_connection_truncates_oversized_reason() -> None:
1091
+ harness = _SupervisorHarness()
1092
+ socket = _FakeSocket()
1093
+ harness.connection = cast(ClientConnection, socket)
1094
+
1095
+ await harness.close_connection("x" * 200)
1096
+
1097
+ assert socket.close_calls[0][0] == 4000
1098
+ sent_reason = socket.close_calls[0][1]
1099
+ assert len(sent_reason.encode("utf-8")) <= 123
1100
+ assert sent_reason == "x" * 123
1101
+
1102
+
1103
+ @pytest.mark.asyncio
1104
+ async def test_disconnect_classification_applies_when_not_forced(
1105
+ monkeypatch: pytest.MonkeyPatch,
1106
+ ) -> None:
1107
+ harness = _SupervisorHarness()
1108
+ harness.disconnect_decision = ReconnectDecision(should_reconnect=False)
1109
+
1110
+ monkeypatch.setattr(
1111
+ "phoenix_channels_python_client.supervisor.connect",
1112
+ lambda _: asyncio.sleep(0, result=cast(ClientConnection, _FakeSocket())),
1113
+ )
1114
+
1115
+ await harness._supervisor_loop()
1116
+
1117
+ assert harness.wait_delays == []
1118
+
1119
+
1120
+ @pytest.mark.asyncio
1121
+ async def test_forced_close_bypasses_disconnect_classification(
1122
+ monkeypatch: pytest.MonkeyPatch,
1123
+ ) -> None:
1124
+ harness = _SupervisorHarness()
1125
+ harness._forced_close_pending = True
1126
+ # What the real _classify_disconnect would decide for whatever code the
1127
+ # remote happened to echo back; the forced-close path must not consult it.
1128
+ harness.disconnect_decision = ReconnectDecision(should_reconnect=False)
1129
+
1130
+ monkeypatch.setattr(
1131
+ "phoenix_channels_python_client.supervisor.connect",
1132
+ lambda _: asyncio.sleep(0, result=cast(ClientConnection, _FakeSocket())),
1133
+ )
1134
+
1135
+ await harness._supervisor_loop()
1136
+
1137
+ assert harness.wait_delays == [0.001]
1138
+ assert harness._forced_close_pending is False