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.
Files changed (41) hide show
  1. {phoenix_channels_python_client-0.3.1/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.0}/PKG-INFO +6 -5
  2. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/README.md +5 -4
  3. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/__init__.py +1 -1
  4. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client.py +7 -3
  5. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client_types.py +4 -1
  6. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/reconnect_controller.py +29 -7
  7. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/supervisor.py +24 -8
  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
  9. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/pyproject.toml +1 -1
  10. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_connection.py +5 -2
  11. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect.py +111 -5
  12. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect_policy_invariants.py +56 -5
  13. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_shutdown_races.py +54 -3
  14. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_supervisor_internals.py +24 -1
  15. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/LICENSE +0 -0
  16. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/MANIFEST.in +0 -0
  17. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/authentication.py +0 -0
  18. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client_state_machine.py +0 -0
  19. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/diagnostics.py +0 -0
  20. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/exceptions.py +0 -0
  21. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/phx_messages.py +0 -0
  22. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/protocol_handler.py +0 -0
  23. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  24. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/topic_runtime.py +0 -0
  25. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/topic_subscription.py +0 -0
  26. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/utils.py +0 -0
  27. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/SOURCES.txt +0 -0
  28. {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
  29. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  30. {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
  31. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/setup.cfg +0 -0
  32. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_cancel_and_wait.py +0 -0
  33. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_channel_recovery.py +0 -0
  34. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_client_state.py +0 -0
  35. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_heartbeat.py +0 -0
  36. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_protocol_handler.py +0 -0
  37. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect_stress.py +0 -0
  38. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_run_forever_signals.py +0 -0
  39. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_security.py +0 -0
  40. {phoenix_channels_python_client-0.3.1 → phoenix_channels_python_client-0.4.0}/tests/test_subscriptions.py +0 -0
  41. {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.1
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; 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, 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 | Stop, unless `reconnect_on_normal_close=True` |
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; 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, 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 | Stop, unless `reconnect_on_normal_close=True` |
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 |
@@ -19,7 +19,7 @@ from phoenix_channels_python_client.protocol_handler import (
19
19
  )
20
20
  from phoenix_channels_python_client.utils import setup_logging
21
21
 
22
- __version__ = "0.3.1"
22
+ __version__ = "0.4.0"
23
23
  __author__ = "Phoenix Channels Python Client"
24
24
 
25
25
  __all__ = [
@@ -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
- async def _shutdown(self, reason: str) -> None:
243
- self.logger.info("Event loop shutting down! reason=%s", reason)
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
- reconnect_on_normal_close: bool = False
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 = ReconnectDecision(
215
- should_reconnect=False,
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
- if self._state != ClientState.SHUTTING_DOWN:
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._terminal_error = decision.terminal_error
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._terminal_error = PHXConnectionError(
368
- "Reconnect suppressed after repeated rapid disconnects. "
369
- "Likely duplicate connection or unstable endpoint."
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.1
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; 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, 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 | Stop, unless `reconnect_on_normal_close=True` |
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 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phoenix-channels-python-client"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "A Python client library for connecting to Phoenix Channels"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 test_run_forever_returns_when_the_server_closes_normally(
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(phoenix_server) as 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, received: asyncio.Queue[ChannelMessage]
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
- async def test_a_normal_close_does_not_reconnect_by_default(
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 = CloseCode.NORMAL_CLOSURE
625
+ phoenix_server.close_on_join_code = close_code
598
626
 
599
- async with make_client(phoenix_server, reconnect_policy=FAST_RECONNECT) as 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 API_KEY, STOP_REASON, derive_policy, make_client
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
- client = make_client(phoenix_server)
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 ReconnectDecision
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: