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.
- {phoenix_channels_python_client-0.2.2/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.2.4}/PKG-INFO +5 -5
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/README.md +4 -4
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/__init__.py +3 -1
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/client.py +7 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/phx_messages.py +3 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/protocol_handler.py +6 -2
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/reconnect_controller.py +9 -5
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/supervisor.py +75 -11
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4/phoenix_channels_python_client.egg-info}/PKG-INFO +5 -5
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/pyproject.toml +1 -1
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/tests/test_internal_components.py +160 -1
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/LICENSE +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/client_types.py +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/topic_runtime.py +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/topic_subscription.py +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client/utils.py +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client.egg-info/SOURCES.txt +0 -0
- {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
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
- {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
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.2.2 → phoenix_channels_python_client-0.2.4}/tests/test_reconnect_policy_invariants.py +0 -0
- {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.
|
|
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/
|
|
46
|
-
cd
|
|
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/
|
|
58
|
-
cd
|
|
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/
|
|
17
|
-
cd
|
|
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/
|
|
29
|
-
cd
|
|
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.
|
|
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:
|
|
@@ -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
|
|
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 ==
|
|
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 {
|
|
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
|
|
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
|
|
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 ==
|
|
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 ==
|
|
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
|
|
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=
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
46
|
-
cd
|
|
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/
|
|
58
|
-
cd
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|