phoenix-channels-python-client 0.3.1__tar.gz → 0.4.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.
- {phoenix_channels_python_client-0.3.1/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.0}/PKG-INFO +6 -5
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/README.md +5 -4
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/__init__.py +1 -1
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client.py +7 -3
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client_types.py +4 -1
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/reconnect_controller.py +29 -7
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/supervisor.py +24 -8
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0/phoenix_channels_python_client.egg-info}/PKG-INFO +6 -5
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/pyproject.toml +1 -1
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_connection.py +5 -2
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect.py +111 -5
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect_policy_invariants.py +56 -5
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_shutdown_races.py +54 -3
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_supervisor_internals.py +24 -1
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/LICENSE +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/authentication.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/diagnostics.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/phx_messages.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/shutdown_signals.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/topic_runtime.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/topic_subscription.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/utils.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/SOURCES.txt +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_channel_recovery.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_client_state.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_heartbeat.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect_stress.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_run_forever_signals.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_security.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_subscriptions.py +0 -0
- {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_topic_runtime_internals.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -310,18 +310,19 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
310
310
|
|
|
311
311
|
## Connection Lifecycle and Errors
|
|
312
312
|
|
|
313
|
-
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying
|
|
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`.
|
|
313
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `PHXConnectionError`; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
|
|
314
|
+
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
315
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
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.
|
|
317
317
|
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
318
318
|
|
|
319
319
|
### Reconnects
|
|
320
320
|
|
|
321
|
-
| Close code | Behaviour (defaults) |
|
|
321
|
+
| Close code or handshake | Behaviour (defaults) |
|
|
322
322
|
|---|---|
|
|
323
|
-
| 1000, 1001 |
|
|
323
|
+
| 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
|
|
324
324
|
| 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
|
|
325
|
+
| Handshake refused with HTTP 403 | Stop with `PHXConnectionError`; other statuses (401, 5xx, …) retry with the backoff below |
|
|
325
326
|
| 1012 | Reconnect after at least a random 1–5 s |
|
|
326
327
|
| 1013 | Reconnect after at least a random 30–60 s |
|
|
327
328
|
| Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
|
|
@@ -279,18 +279,19 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
279
279
|
|
|
280
280
|
## Connection Lifecycle and Errors
|
|
281
281
|
|
|
282
|
-
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying
|
|
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`.
|
|
282
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `PHXConnectionError`; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
|
|
283
|
+
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
284
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
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.
|
|
286
286
|
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
287
287
|
|
|
288
288
|
### Reconnects
|
|
289
289
|
|
|
290
|
-
| Close code | Behaviour (defaults) |
|
|
290
|
+
| Close code or handshake | Behaviour (defaults) |
|
|
291
291
|
|---|---|
|
|
292
|
-
| 1000, 1001 |
|
|
292
|
+
| 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
|
|
293
293
|
| 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
|
|
294
|
+
| Handshake refused with HTTP 403 | Stop with `PHXConnectionError`; other statuses (401, 5xx, …) retry with the backoff below |
|
|
294
295
|
| 1012 | Reconnect after at least a random 1–5 s |
|
|
295
296
|
| 1013 | Reconnect after at least a random 30–60 s |
|
|
296
297
|
| Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
|
|
@@ -234,18 +234,22 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
234
234
|
if task is None:
|
|
235
235
|
if self._is_fully_closed:
|
|
236
236
|
return
|
|
237
|
+
self._request_stop()
|
|
237
238
|
task = self._shutdown_task = asyncio.create_task(self._shutdown(reason))
|
|
238
239
|
# Every caller waits for the same shutdown, and one caller's
|
|
239
240
|
# cancellation never aborts it.
|
|
240
241
|
await asyncio.shield(task)
|
|
241
242
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
243
|
+
def _request_stop(self) -> None:
|
|
244
|
+
# Runs inside the shutdown() call, not in its task: a refusal or close landing
|
|
245
|
+
# before the task's first step must already see the stop.
|
|
245
246
|
if self._state not in (ClientState.SHUTTING_DOWN, ClientState.CLOSED):
|
|
246
247
|
self._transition_state(ClientState.SHUTTING_DOWN)
|
|
247
248
|
self._shutdown_event.set()
|
|
248
249
|
|
|
250
|
+
async def _shutdown(self, reason: str) -> None:
|
|
251
|
+
self.logger.info("Event loop shutting down! reason=%s", reason)
|
|
252
|
+
|
|
249
253
|
await self._unsubscribe_all()
|
|
250
254
|
# Includes on_topic_lost callbacks still running for topics already gone.
|
|
251
255
|
await cancel_and_wait(*self._lifecycle_tasks)
|
|
@@ -40,7 +40,10 @@ class ReconnectPolicy(BaseModel):
|
|
|
40
40
|
factor: PositiveFloat = 2.0
|
|
41
41
|
max_delay_s: NonNegativeFloat = 30.0
|
|
42
42
|
stable_reset_s: PositiveFloat = 60.0
|
|
43
|
-
|
|
43
|
+
# Phoenix >= 1.8.8 closes deliberate disconnects with 1001 ("reconnect
|
|
44
|
+
# expected"), and Bandit answers a close the peer started with 1000, so
|
|
45
|
+
# neither code is a stop signal.
|
|
46
|
+
reconnect_on_normal_close: bool = True
|
|
44
47
|
policy_violation_is_terminal: bool = True
|
|
45
48
|
service_restart_min_delay_s: NonNegativeFloat = 1.0
|
|
46
49
|
service_restart_max_delay_s: NonNegativeFloat = 5.0
|
|
@@ -4,9 +4,10 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import random
|
|
6
6
|
from collections import deque
|
|
7
|
+
from http import HTTPStatus
|
|
7
8
|
|
|
8
9
|
from websockets import ClientConnection
|
|
9
|
-
from websockets.exceptions import ConnectionClosed
|
|
10
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
|
10
11
|
from websockets.frames import CloseCode
|
|
11
12
|
|
|
12
13
|
from phoenix_channels_python_client.client_types import (
|
|
@@ -15,6 +16,20 @@ from phoenix_channels_python_client.client_types import (
|
|
|
15
16
|
)
|
|
16
17
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
17
18
|
|
|
19
|
+
# The Band platform answers every auth refusal with 403, and its websocket spec
|
|
20
|
+
# (thenvoi-platform spec/websocket-api.draft.md) makes a rejected reconnect terminal.
|
|
21
|
+
# A 401 could only come from an edge layer, so it and every other status retry.
|
|
22
|
+
TERMINAL_HANDSHAKE_STATUS = HTTPStatus.FORBIDDEN
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _terminal_decision(cause: str) -> ReconnectDecision:
|
|
26
|
+
return ReconnectDecision(
|
|
27
|
+
should_reconnect=False,
|
|
28
|
+
terminal_error=PHXConnectionError(
|
|
29
|
+
f"Reconnect disabled due to terminal {cause}"
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
18
33
|
|
|
19
34
|
class ReconnectControllerMixin:
|
|
20
35
|
logger: logging.Logger
|
|
@@ -211,12 +226,8 @@ class ReconnectControllerMixin:
|
|
|
211
226
|
and self.reconnect_policy.policy_violation_is_terminal
|
|
212
227
|
):
|
|
213
228
|
reason = close_reason or "policy violation"
|
|
214
|
-
decision =
|
|
215
|
-
|
|
216
|
-
terminal_error=PHXConnectionError(
|
|
217
|
-
"Reconnect disabled due to terminal close code "
|
|
218
|
-
f"{CloseCode.POLICY_VIOLATION} ({reason})"
|
|
219
|
-
),
|
|
229
|
+
decision = _terminal_decision(
|
|
230
|
+
f"close code {CloseCode.POLICY_VIOLATION} ({reason})"
|
|
220
231
|
)
|
|
221
232
|
self.logger.debug(
|
|
222
233
|
"Disconnect classification result. close_code=%s close_reason=%s "
|
|
@@ -297,6 +308,17 @@ class ReconnectControllerMixin:
|
|
|
297
308
|
)
|
|
298
309
|
return decision
|
|
299
310
|
|
|
311
|
+
def _classify_connect_failure(self, error: Exception) -> ReconnectDecision:
|
|
312
|
+
if (
|
|
313
|
+
isinstance(error, InvalidStatus)
|
|
314
|
+
and error.response.status_code == TERMINAL_HANDSHAKE_STATUS
|
|
315
|
+
):
|
|
316
|
+
return _terminal_decision(
|
|
317
|
+
f"handshake status {TERMINAL_HANDSHAKE_STATUS} "
|
|
318
|
+
f"({TERMINAL_HANDSHAKE_STATUS.phrase})"
|
|
319
|
+
)
|
|
320
|
+
return ReconnectDecision(should_reconnect=True)
|
|
321
|
+
|
|
300
322
|
def _apply_disconnect_delay_override(
|
|
301
323
|
self,
|
|
302
324
|
computed_delay_s: float,
|
|
@@ -67,6 +67,7 @@ class _SupervisorRuntimeDeps(Protocol):
|
|
|
67
67
|
def _classify_disconnect(
|
|
68
68
|
self, close_code: int | None, close_reason: str
|
|
69
69
|
) -> ReconnectDecision: ...
|
|
70
|
+
def _classify_connect_failure(self, error: Exception) -> ReconnectDecision: ...
|
|
70
71
|
def _apply_disconnect_delay_override(
|
|
71
72
|
self, computed_delay_s: float, decision: ReconnectDecision
|
|
72
73
|
) -> float: ...
|
|
@@ -262,10 +263,19 @@ class SupervisorMixin:
|
|
|
262
263
|
)
|
|
263
264
|
self.logger.error("Connection failed and auto_reconnect=False: %s", exc)
|
|
264
265
|
return False
|
|
266
|
+
# A requested stop outranks a refusal's terminal error.
|
|
267
|
+
if self._shutdown_event.is_set():
|
|
268
|
+
self.logger.debug(
|
|
269
|
+
"Ignoring a connect failure after shutdown began: %s", exc
|
|
270
|
+
)
|
|
271
|
+
return False
|
|
272
|
+
decision = self._deps._classify_connect_failure(exc)
|
|
273
|
+
if decision.terminal_error is not None:
|
|
274
|
+
self._record_terminal_error(decision.terminal_error)
|
|
275
|
+
return False
|
|
265
276
|
if self._suppressed_after_disconnect(0.0):
|
|
266
277
|
return False
|
|
267
|
-
|
|
268
|
-
self._deps._transition_state(ClientState.RECONNECTING)
|
|
278
|
+
self._deps._transition_state(ClientState.RECONNECTING)
|
|
269
279
|
return True
|
|
270
280
|
|
|
271
281
|
def _adopt_connection(self, connection: ClientConnection) -> bool:
|
|
@@ -347,8 +357,7 @@ class SupervisorMixin:
|
|
|
347
357
|
else:
|
|
348
358
|
decision = self._deps._classify_disconnect(close.code, close.reason)
|
|
349
359
|
if decision.terminal_error is not None:
|
|
350
|
-
self.
|
|
351
|
-
self.logger.error("%s", self._terminal_error)
|
|
360
|
+
self._record_terminal_error(decision.terminal_error)
|
|
352
361
|
return None
|
|
353
362
|
|
|
354
363
|
if not decision.should_reconnect:
|
|
@@ -364,13 +373,20 @@ class SupervisorMixin:
|
|
|
364
373
|
self._deps._record_disconnect(connection_uptime_s=uptime_s)
|
|
365
374
|
if not self._deps._should_suppress_reconnect():
|
|
366
375
|
return False
|
|
367
|
-
self.
|
|
368
|
-
|
|
369
|
-
|
|
376
|
+
self._record_terminal_error(
|
|
377
|
+
PHXConnectionError(
|
|
378
|
+
"Reconnect suppressed after repeated rapid disconnects. "
|
|
379
|
+
"Likely duplicate connection or unstable endpoint."
|
|
380
|
+
)
|
|
370
381
|
)
|
|
371
|
-
self.logger.error("%s", self._terminal_error)
|
|
372
382
|
return True
|
|
373
383
|
|
|
384
|
+
def _record_terminal_error(self, error: PHXConnectionError) -> None:
|
|
385
|
+
self._terminal_error = error
|
|
386
|
+
# Log records stay static: the cause reaches the caller as the raised error.
|
|
387
|
+
self.logger.error("Reconnect stopped by a terminal error")
|
|
388
|
+
self._settle_initial_connection(error)
|
|
389
|
+
|
|
374
390
|
def _settle_initial_connection(self, error: Exception | None) -> None:
|
|
375
391
|
"""Resolve ``__aenter__``'s wait, unless it already ended or was cancelled."""
|
|
376
392
|
future = self._initial_connection_future
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -310,18 +310,19 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
310
310
|
|
|
311
311
|
## Connection Lifecycle and Errors
|
|
312
312
|
|
|
313
|
-
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying
|
|
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`.
|
|
313
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `PHXConnectionError`; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
|
|
314
|
+
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
315
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
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.
|
|
317
317
|
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
318
318
|
|
|
319
319
|
### Reconnects
|
|
320
320
|
|
|
321
|
-
| Close code | Behaviour (defaults) |
|
|
321
|
+
| Close code or handshake | Behaviour (defaults) |
|
|
322
322
|
|---|---|
|
|
323
|
-
| 1000, 1001 |
|
|
323
|
+
| 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
|
|
324
324
|
| 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
|
|
325
|
+
| Handshake refused with HTTP 403 | Stop with `PHXConnectionError`; other statuses (401, 5xx, …) retry with the backoff below |
|
|
325
326
|
| 1012 | Reconnect after at least a random 1–5 s |
|
|
326
327
|
| 1013 | Reconnect after at least a random 30–60 s |
|
|
327
328
|
| Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
|
|
@@ -19,6 +19,7 @@ from tests.support import (
|
|
|
19
19
|
EVENT,
|
|
20
20
|
FAST_RECONNECT,
|
|
21
21
|
STOP_REASON,
|
|
22
|
+
STOPS_ON_NORMAL_CLOSE,
|
|
22
23
|
TOPIC,
|
|
23
24
|
UNPARSEABLE_FRAME,
|
|
24
25
|
deliver,
|
|
@@ -68,10 +69,12 @@ async def test_a_string_version_connects_and_receives_a_message(
|
|
|
68
69
|
)
|
|
69
70
|
|
|
70
71
|
|
|
71
|
-
async def
|
|
72
|
+
async def test_run_forever_returns_on_a_normal_close_when_reconnecting_on_it_is_off(
|
|
72
73
|
phoenix_server: FakePhoenixServer,
|
|
73
74
|
) -> None:
|
|
74
|
-
async with make_client(
|
|
75
|
+
async with make_client(
|
|
76
|
+
phoenix_server, reconnect_policy=STOPS_ON_NORMAL_CLOSE
|
|
77
|
+
) as client:
|
|
75
78
|
await client.subscribe_to_topic(TOPIC)
|
|
76
79
|
run = asyncio.create_task(client.run_forever())
|
|
77
80
|
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from enum import StrEnum
|
|
5
|
+
from http import HTTPStatus
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
@@ -14,6 +15,7 @@ from phoenix_channels_python_client.exceptions import PHXConnectionError, PHXTop
|
|
|
14
15
|
from phoenix_channels_python_client.phx_messages import ChannelMessage, Event, PHXEvent
|
|
15
16
|
from tests.fake_server import FakePhoenixServer
|
|
16
17
|
from tests.support import (
|
|
18
|
+
API_KEY,
|
|
17
19
|
ASYNC_TIMEOUT_S,
|
|
18
20
|
EVENT,
|
|
19
21
|
FAST_RECONNECT,
|
|
@@ -21,6 +23,8 @@ from tests.support import (
|
|
|
21
23
|
LEAVE_TIMEOUT_S,
|
|
22
24
|
OTHER_TOPIC,
|
|
23
25
|
STOP_REASON,
|
|
26
|
+
STOPS_ON_NORMAL_CLOSE,
|
|
27
|
+
TERMINAL_REFUSAL,
|
|
24
28
|
TOPIC,
|
|
25
29
|
UNPARSEABLE_FRAME,
|
|
26
30
|
BusyCallback,
|
|
@@ -28,7 +32,9 @@ from tests.support import (
|
|
|
28
32
|
ReconnectCounter,
|
|
29
33
|
deliver,
|
|
30
34
|
derive_policy,
|
|
35
|
+
each_normal_close_code,
|
|
31
36
|
each_protocol,
|
|
37
|
+
each_retried_refusal,
|
|
32
38
|
expect_message,
|
|
33
39
|
make_client,
|
|
34
40
|
reconnect,
|
|
@@ -56,6 +62,9 @@ UNRECOGNISED_CLOSE_CODE = 4001
|
|
|
56
62
|
# More than one, so a retry follows a failed retry.
|
|
57
63
|
REFUSED_HANDSHAKES = 2
|
|
58
64
|
|
|
65
|
+
TERMINAL_REFUSAL_ERROR = f"terminal handshake status {int(TERMINAL_REFUSAL)}"
|
|
66
|
+
TERMINAL_STOP_LOG = "Reconnect stopped by a terminal error"
|
|
67
|
+
|
|
59
68
|
|
|
60
69
|
class RejoinOutcome(StrEnum):
|
|
61
70
|
REJOINED = "rejoined"
|
|
@@ -144,8 +153,11 @@ async def test_with_auto_reconnect_disabled_a_service_restart_disconnects(
|
|
|
144
153
|
assert await wait_for_condition(lambda: client.connection is None)
|
|
145
154
|
|
|
146
155
|
|
|
156
|
+
@each_retried_refusal
|
|
147
157
|
async def test_consecutive_failed_connects_keep_retrying_until_the_server_accepts(
|
|
148
|
-
phoenix_server: FakePhoenixServer,
|
|
158
|
+
phoenix_server: FakePhoenixServer,
|
|
159
|
+
received: asyncio.Queue[ChannelMessage],
|
|
160
|
+
refusal: HTTPStatus,
|
|
149
161
|
) -> None:
|
|
150
162
|
reconnects = ReconnectCounter()
|
|
151
163
|
client = make_client(
|
|
@@ -154,7 +166,7 @@ async def test_consecutive_failed_connects_keep_retrying_until_the_server_accept
|
|
|
154
166
|
async with client:
|
|
155
167
|
await client.subscribe_to_topic(TOPIC, received.put)
|
|
156
168
|
|
|
157
|
-
with phoenix_server.refusing(REFUSED_HANDSHAKES):
|
|
169
|
+
with phoenix_server.refusing(REFUSED_HANDSHAKES, refusal):
|
|
158
170
|
await reconnect(phoenix_server, client)
|
|
159
171
|
|
|
160
172
|
assert phoenix_server.refused_handshakes == REFUSED_HANDSHAKES
|
|
@@ -590,19 +602,113 @@ async def test_a_policy_violation_close_stops_reconnecting_with_a_terminal_error
|
|
|
590
602
|
|
|
591
603
|
|
|
592
604
|
@each_protocol
|
|
593
|
-
|
|
605
|
+
@each_normal_close_code
|
|
606
|
+
async def test_a_normal_close_reconnects_by_default(
|
|
594
607
|
phoenix_server: FakePhoenixServer,
|
|
608
|
+
received: asyncio.Queue[ChannelMessage],
|
|
609
|
+
close_code: CloseCode,
|
|
610
|
+
) -> None:
|
|
611
|
+
async with make_client(phoenix_server, reconnect_policy=FAST_RECONNECT) as client:
|
|
612
|
+
await client.subscribe_to_topic(TOPIC, received.put)
|
|
613
|
+
|
|
614
|
+
await reconnect(phoenix_server, client, code=close_code)
|
|
615
|
+
|
|
616
|
+
await assert_delivers_after_rejoin(phoenix_server, client, TOPIC, received)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@each_protocol
|
|
620
|
+
@each_normal_close_code
|
|
621
|
+
async def test_a_normal_close_stops_when_reconnecting_on_it_is_off(
|
|
622
|
+
phoenix_server: FakePhoenixServer, close_code: CloseCode
|
|
595
623
|
) -> None:
|
|
596
624
|
phoenix_server.close_on_join_ids.add(phoenix_server.next_client_id)
|
|
597
|
-
phoenix_server.close_on_join_code =
|
|
625
|
+
phoenix_server.close_on_join_code = close_code
|
|
598
626
|
|
|
599
|
-
async with make_client(
|
|
627
|
+
async with make_client(
|
|
628
|
+
phoenix_server, reconnect_policy=STOPS_ON_NORMAL_CLOSE
|
|
629
|
+
) as client:
|
|
600
630
|
await client.subscribe_to_topic(TOPIC)
|
|
601
631
|
await asyncio.wait_for(client.run_forever(), ASYNC_TIMEOUT_S)
|
|
602
632
|
|
|
603
633
|
assert phoenix_server.get_connection_attempts(FakePhoenixServer.SOCKET_PATH) == 1
|
|
604
634
|
|
|
605
635
|
|
|
636
|
+
async def test_disabling_auto_reconnect_wins_over_a_normal_close(
|
|
637
|
+
phoenix_server: FakePhoenixServer,
|
|
638
|
+
) -> None:
|
|
639
|
+
async with make_client(phoenix_server, reconnect_policy=FAST_RECONNECT) as client:
|
|
640
|
+
# What band-sdk's disable_reconnect does on a supersede: flip it once connected.
|
|
641
|
+
client.auto_reconnect = False
|
|
642
|
+
|
|
643
|
+
await phoenix_server.close_all_clients(code=CloseCode.GOING_AWAY)
|
|
644
|
+
|
|
645
|
+
await asyncio.wait_for(client.run_forever(), ASYNC_TIMEOUT_S)
|
|
646
|
+
|
|
647
|
+
assert phoenix_server.get_connection_attempts(FakePhoenixServer.SOCKET_PATH) == 1
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
async def test_a_refused_handshake_after_a_close_is_terminal(
|
|
651
|
+
phoenix_server: FakePhoenixServer, caplog: pytest.LogCaptureFixture
|
|
652
|
+
) -> None:
|
|
653
|
+
async with make_client(phoenix_server, reconnect_policy=FAST_RECONNECT) as client:
|
|
654
|
+
with phoenix_server.refusing(REFUSED_HANDSHAKES, TERMINAL_REFUSAL):
|
|
655
|
+
await phoenix_server.close_all_clients(code=CloseCode.GOING_AWAY)
|
|
656
|
+
|
|
657
|
+
with pytest.raises(
|
|
658
|
+
PHXConnectionError, match=TERMINAL_REFUSAL_ERROR
|
|
659
|
+
) as stop:
|
|
660
|
+
await asyncio.wait_for(client.run_forever(), ASYNC_TIMEOUT_S)
|
|
661
|
+
|
|
662
|
+
assert phoenix_server.refused_handshakes == 1
|
|
663
|
+
assert API_KEY not in str(stop.value)
|
|
664
|
+
assert TERMINAL_STOP_LOG in caplog.text
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
async def test_a_refused_handshake_on_entry_raises_instead_of_retrying(
|
|
668
|
+
phoenix_server: FakePhoenixServer,
|
|
669
|
+
) -> None:
|
|
670
|
+
client = make_client(phoenix_server, reconnect_policy=FAST_RECONNECT)
|
|
671
|
+
|
|
672
|
+
with (
|
|
673
|
+
phoenix_server.refusing(REFUSED_HANDSHAKES, TERMINAL_REFUSAL),
|
|
674
|
+
pytest.raises(PHXConnectionError, match=TERMINAL_REFUSAL_ERROR) as stop,
|
|
675
|
+
):
|
|
676
|
+
await asyncio.wait_for(client.__aenter__(), ASYNC_TIMEOUT_S)
|
|
677
|
+
|
|
678
|
+
assert phoenix_server.refused_handshakes == 1
|
|
679
|
+
assert API_KEY not in str(stop.value)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
async def test_refusals_on_entry_raise_the_suppression_error_not_a_generic_one(
|
|
683
|
+
phoenix_server: FakePhoenixServer,
|
|
684
|
+
) -> None:
|
|
685
|
+
policy = derive_policy(
|
|
686
|
+
FAST_RECONNECT, rapid_suppress_disconnect_count=SUPPRESS_AFTER_DISCONNECTS
|
|
687
|
+
)
|
|
688
|
+
client = make_client(phoenix_server, reconnect_policy=policy)
|
|
689
|
+
|
|
690
|
+
with (
|
|
691
|
+
phoenix_server.refusing(2 * SUPPRESS_AFTER_DISCONNECTS),
|
|
692
|
+
pytest.raises(PHXConnectionError, match="Reconnect suppressed"),
|
|
693
|
+
):
|
|
694
|
+
await asyncio.wait_for(client.__aenter__(), ASYNC_TIMEOUT_S)
|
|
695
|
+
|
|
696
|
+
assert phoenix_server.refused_handshakes == SUPPRESS_AFTER_DISCONNECTS
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@each_retried_refusal
|
|
700
|
+
async def test_a_non_forbidden_refusal_on_entry_is_retried_until_accepted(
|
|
701
|
+
phoenix_server: FakePhoenixServer, refusal: HTTPStatus
|
|
702
|
+
) -> None:
|
|
703
|
+
client = make_client(phoenix_server, reconnect_policy=FAST_RECONNECT)
|
|
704
|
+
|
|
705
|
+
with phoenix_server.refusing(REFUSED_HANDSHAKES, refusal):
|
|
706
|
+
async with client:
|
|
707
|
+
pass
|
|
708
|
+
|
|
709
|
+
assert phoenix_server.refused_handshakes == REFUSED_HANDSHAKES
|
|
710
|
+
|
|
711
|
+
|
|
606
712
|
@each_protocol
|
|
607
713
|
async def test_an_unrecognised_close_code_reconnects(
|
|
608
714
|
phoenix_server: FakePhoenixServer, received: asyncio.Queue[ChannelMessage]
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from collections import deque
|
|
4
4
|
from collections.abc import Callable
|
|
5
|
+
from http import HTTPStatus
|
|
5
6
|
from itertools import pairwise
|
|
6
7
|
from urllib.parse import parse_qs, urlsplit
|
|
7
8
|
|
|
@@ -11,7 +12,17 @@ from websockets.frames import CloseCode
|
|
|
11
12
|
from phoenix_channels_python_client.client import PHXChannelsClient, ReconnectPolicy
|
|
12
13
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
13
14
|
from tests.fake_server import FakePhoenixServer
|
|
14
|
-
from tests.support import
|
|
15
|
+
from tests.support import (
|
|
16
|
+
API_KEY,
|
|
17
|
+
STOP_REASON,
|
|
18
|
+
STOPS_ON_NORMAL_CLOSE,
|
|
19
|
+
TERMINAL_REFUSAL,
|
|
20
|
+
derive_policy,
|
|
21
|
+
each_normal_close_code,
|
|
22
|
+
each_retried_refusal,
|
|
23
|
+
make_client,
|
|
24
|
+
refused,
|
|
25
|
+
)
|
|
15
26
|
|
|
16
27
|
# No jitter, and every pre-cooldown delay stays below the cooldown base.
|
|
17
28
|
COOLDOWN_POLICY = ReconnectPolicy(
|
|
@@ -70,14 +81,32 @@ def test_the_logged_socket_url_masks_the_api_key() -> None:
|
|
|
70
81
|
assert API_KEY not in client.channel_socket_url_redacted
|
|
71
82
|
|
|
72
83
|
|
|
84
|
+
@each_normal_close_code
|
|
85
|
+
def test_the_default_policy_reconnects_after_a_normal_close(
|
|
86
|
+
close_code: CloseCode,
|
|
87
|
+
) -> None:
|
|
88
|
+
decision = _make_client()._classify_disconnect(close_code, STOP_REASON)
|
|
89
|
+
|
|
90
|
+
assert decision.should_reconnect is True
|
|
91
|
+
assert decision.terminal_error is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@each_normal_close_code
|
|
95
|
+
def test_a_policy_not_reconnecting_on_a_normal_close_stops_without_an_error(
|
|
96
|
+
close_code: CloseCode,
|
|
97
|
+
) -> None:
|
|
98
|
+
client = _make_client(STOPS_ON_NORMAL_CLOSE)
|
|
99
|
+
|
|
100
|
+
decision = client._classify_disconnect(close_code, STOP_REASON)
|
|
101
|
+
|
|
102
|
+
assert decision.should_reconnect is False
|
|
103
|
+
assert decision.terminal_error is None
|
|
104
|
+
|
|
105
|
+
|
|
73
106
|
def test_close_codes_are_classified_by_the_policy() -> None:
|
|
74
107
|
policy = ReconnectPolicy()
|
|
75
108
|
client = _make_client(policy)
|
|
76
109
|
|
|
77
|
-
normal = client._classify_disconnect(CloseCode.NORMAL_CLOSURE, STOP_REASON)
|
|
78
|
-
assert normal.should_reconnect is False
|
|
79
|
-
assert normal.terminal_error is None
|
|
80
|
-
|
|
81
110
|
violation = client._classify_disconnect(CloseCode.POLICY_VIOLATION, STOP_REASON)
|
|
82
111
|
assert violation.should_reconnect is False
|
|
83
112
|
assert isinstance(violation.terminal_error, PHXConnectionError)
|
|
@@ -93,6 +122,28 @@ def test_close_codes_are_classified_by_the_policy() -> None:
|
|
|
93
122
|
assert busy.max_delay_s == policy.try_again_later_max_delay_s
|
|
94
123
|
|
|
95
124
|
|
|
125
|
+
def test_a_forbidden_handshake_is_a_terminal_connect_failure() -> None:
|
|
126
|
+
decision = _make_client()._classify_connect_failure(refused(TERMINAL_REFUSAL))
|
|
127
|
+
|
|
128
|
+
assert decision.should_reconnect is False
|
|
129
|
+
assert isinstance(decision.terminal_error, PHXConnectionError)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@each_retried_refusal
|
|
133
|
+
def test_a_refusal_other_than_forbidden_is_retried(refusal: HTTPStatus) -> None:
|
|
134
|
+
decision = _make_client()._classify_connect_failure(refused(refusal))
|
|
135
|
+
|
|
136
|
+
assert decision.should_reconnect is True
|
|
137
|
+
assert decision.terminal_error is None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_a_failed_tcp_connect_is_retried() -> None:
|
|
141
|
+
decision = _make_client()._classify_connect_failure(ConnectionRefusedError())
|
|
142
|
+
|
|
143
|
+
assert decision.should_reconnect is True
|
|
144
|
+
assert decision.terminal_error is None
|
|
145
|
+
|
|
146
|
+
|
|
96
147
|
def test_the_rapid_cooldown_steps_from_its_base_up_to_its_cap() -> None:
|
|
97
148
|
client = _make_client(COOLDOWN_POLICY)
|
|
98
149
|
base = COOLDOWN_POLICY.rapid_cooldown_base_s
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
from http import HTTPStatus
|
|
4
5
|
from typing import Self
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
@@ -17,13 +18,18 @@ from tests.support import (
|
|
|
17
18
|
FAST_RECONNECT,
|
|
18
19
|
LEAVE_TIMEOUT_S,
|
|
19
20
|
STOP_REASON,
|
|
21
|
+
STOPS_ON_NORMAL_CLOSE,
|
|
22
|
+
TERMINAL_REFUSAL,
|
|
20
23
|
TOPIC,
|
|
21
24
|
UNPARSEABLE_FRAME,
|
|
22
25
|
ReconnectCounter,
|
|
23
26
|
crash_with_rejoin_in_flight,
|
|
24
27
|
deliver,
|
|
28
|
+
derive_policy,
|
|
29
|
+
each_refusal,
|
|
25
30
|
make_client,
|
|
26
31
|
reconnect,
|
|
32
|
+
refused,
|
|
27
33
|
wait_for_condition,
|
|
28
34
|
wait_forever,
|
|
29
35
|
)
|
|
@@ -31,6 +37,13 @@ from tests.support import (
|
|
|
31
37
|
# Long enough for a shutdown that isn't held to finish against the local server.
|
|
32
38
|
SETTLE_S = 0.2
|
|
33
39
|
|
|
40
|
+
# The backoff after a close, long enough that even its jitter's lower half
|
|
41
|
+
# outlasts the test, so only a shutdown can end the wait.
|
|
42
|
+
HELD_BACKOFF_S = 4 * ASYNC_TIMEOUT_S
|
|
43
|
+
HELD_BACKOFF = derive_policy(
|
|
44
|
+
FAST_RECONNECT, base_delay_s=HELD_BACKOFF_S, max_delay_s=HELD_BACKOFF_S
|
|
45
|
+
)
|
|
46
|
+
|
|
34
47
|
|
|
35
48
|
class HeldCallback:
|
|
36
49
|
"""A topic callback that, once cancelled, holds its unwinding until released.
|
|
@@ -105,8 +118,9 @@ async def test_reconnect_landing_during_shutdown_is_closed(
|
|
|
105
118
|
)
|
|
106
119
|
|
|
107
120
|
|
|
121
|
+
@each_refusal
|
|
108
122
|
async def test_a_connect_failure_during_shutdown_closes_cleanly(
|
|
109
|
-
phoenix_server: FakePhoenixServer,
|
|
123
|
+
phoenix_server: FakePhoenixServer, refusal: HTTPStatus
|
|
110
124
|
) -> None:
|
|
111
125
|
client = make_client(phoenix_server, reconnect_policy=FAST_RECONNECT)
|
|
112
126
|
async with client:
|
|
@@ -114,7 +128,7 @@ async def test_a_connect_failure_during_shutdown_closes_cleanly(
|
|
|
114
128
|
run, stop = await shut_down_during_a_held_reconnect(
|
|
115
129
|
phoenix_server, client, held
|
|
116
130
|
)
|
|
117
|
-
with phoenix_server.refusing(1):
|
|
131
|
+
with phoenix_server.refusing(1, refusal):
|
|
118
132
|
phoenix_server.handshake_gate.set()
|
|
119
133
|
# Inside the block: the shutdown would otherwise cancel the
|
|
120
134
|
# supervisor before the held reconnect fails.
|
|
@@ -124,6 +138,42 @@ async def test_a_connect_failure_during_shutdown_closes_cleanly(
|
|
|
124
138
|
assert client._state is ClientState.CLOSED
|
|
125
139
|
|
|
126
140
|
|
|
141
|
+
async def test_a_refusal_is_ignored_from_the_moment_shutdown_is_called(
|
|
142
|
+
phoenix_server: FakePhoenixServer,
|
|
143
|
+
) -> None:
|
|
144
|
+
async with make_client(phoenix_server, reconnect_policy=FAST_RECONNECT) as client:
|
|
145
|
+
stop = asyncio.create_task(client.shutdown(STOP_REASON))
|
|
146
|
+
# shutdown() has run to its first await; the task it created has not started.
|
|
147
|
+
await asyncio.sleep(0)
|
|
148
|
+
|
|
149
|
+
assert client._state is ClientState.SHUTTING_DOWN
|
|
150
|
+
assert client._retry_after_connect_failure(refused(TERMINAL_REFUSAL)) is False
|
|
151
|
+
assert client._terminal_error is None
|
|
152
|
+
|
|
153
|
+
await asyncio.wait_for(stop, ASYNC_TIMEOUT_S)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def test_shutdown_during_the_reconnect_backoff_makes_no_new_connection(
|
|
157
|
+
phoenix_server: FakePhoenixServer,
|
|
158
|
+
) -> None:
|
|
159
|
+
async with make_client(phoenix_server, reconnect_policy=HELD_BACKOFF) as client:
|
|
160
|
+
run = asyncio.create_task(client.run_forever(install_signal_handlers=False))
|
|
161
|
+
attempts = phoenix_server.get_connection_attempts(FakePhoenixServer.SOCKET_PATH)
|
|
162
|
+
await phoenix_server.close_all_clients(code=CloseCode.GOING_AWAY)
|
|
163
|
+
# The supervisor enters the backoff wait straight after this transition.
|
|
164
|
+
assert await wait_for_condition(
|
|
165
|
+
lambda: client._state is ClientState.RECONNECTING
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
await asyncio.wait_for(client.shutdown(STOP_REASON), ASYNC_TIMEOUT_S)
|
|
169
|
+
|
|
170
|
+
assert await asyncio.wait_for(run, ASYNC_TIMEOUT_S) is None
|
|
171
|
+
assert (
|
|
172
|
+
phoenix_server.get_connection_attempts(FakePhoenixServer.SOCKET_PATH)
|
|
173
|
+
== attempts
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
127
177
|
async def test_shutdown_during_recovery_leaves_no_tasks(
|
|
128
178
|
phoenix_server: FakePhoenixServer,
|
|
129
179
|
) -> None:
|
|
@@ -262,7 +312,8 @@ async def test_shutdown_from_a_topic_callback_cancels_the_callback(
|
|
|
262
312
|
async def test_forced_close_racing_shutdown_does_not_leak_into_the_next_session(
|
|
263
313
|
phoenix_server: FakePhoenixServer,
|
|
264
314
|
) -> None:
|
|
265
|
-
|
|
315
|
+
# A leaked forced-close flag would reconnect after the normal close below.
|
|
316
|
+
client = make_client(phoenix_server, reconnect_policy=STOPS_ON_NORMAL_CLOSE)
|
|
266
317
|
async with client:
|
|
267
318
|
await asyncio.wait_for(
|
|
268
319
|
asyncio.gather(
|
|
@@ -6,7 +6,10 @@ import asyncio
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
-
from phoenix_channels_python_client.client_types import
|
|
9
|
+
from phoenix_channels_python_client.client_types import (
|
|
10
|
+
ClientState,
|
|
11
|
+
ReconnectDecision,
|
|
12
|
+
)
|
|
10
13
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
11
14
|
from phoenix_channels_python_client.phx_messages import (
|
|
12
15
|
PHOENIX_TOPIC,
|
|
@@ -40,6 +43,26 @@ async def test_a_suppressed_reconnect_after_a_failed_connect_is_terminal() -> No
|
|
|
40
43
|
await harness._supervisor_loop()
|
|
41
44
|
|
|
42
45
|
assert isinstance(harness._terminal_error, PHXConnectionError)
|
|
46
|
+
assert harness._initial_connection_future is not None
|
|
47
|
+
assert harness._initial_connection_future.exception() is harness._terminal_error
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_a_terminal_connect_failure_ends_the_loop_with_its_error() -> None:
|
|
51
|
+
terminal = PHXConnectionError("refused for good")
|
|
52
|
+
harness = SupervisorHarness(connect_failures=1)
|
|
53
|
+
harness.connect_failure_decision = ReconnectDecision(
|
|
54
|
+
should_reconnect=False, terminal_error=terminal
|
|
55
|
+
)
|
|
56
|
+
# Consulted first, it would replace the terminal error with its own.
|
|
57
|
+
harness.suppress_values = [True]
|
|
58
|
+
|
|
59
|
+
await harness._supervisor_loop()
|
|
60
|
+
|
|
61
|
+
assert harness.connect_attempts == 1
|
|
62
|
+
assert harness._terminal_error is terminal
|
|
63
|
+
assert harness._initial_connection_future is not None
|
|
64
|
+
assert harness._initial_connection_future.exception() is terminal
|
|
65
|
+
assert harness._state is ClientState.CLOSED
|
|
43
66
|
|
|
44
67
|
|
|
45
68
|
async def test_the_initial_connect_retries_before_succeeding() -> None:
|
|
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
|
|
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
|
{phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_security.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|