phoenix-channels-python-client 0.2.4__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phoenix_channels_python_client/__init__.py +5 -4
- phoenix_channels_python_client/client.py +71 -65
- phoenix_channels_python_client/client_state_machine.py +1 -3
- phoenix_channels_python_client/client_types.py +49 -78
- phoenix_channels_python_client/exceptions.py +0 -2
- phoenix_channels_python_client/phx_messages.py +16 -9
- phoenix_channels_python_client/protocol_handler.py +107 -68
- phoenix_channels_python_client/reconnect_controller.py +58 -27
- phoenix_channels_python_client/shutdown_signals.py +120 -0
- phoenix_channels_python_client/supervisor.py +272 -244
- phoenix_channels_python_client/topic_runtime.py +371 -169
- phoenix_channels_python_client/topic_subscription.py +21 -6
- phoenix_channels_python_client/utils.py +33 -15
- phoenix_channels_python_client-0.3.0.dist-info/METADATA +383 -0
- phoenix_channels_python_client-0.3.0.dist-info/RECORD +18 -0
- phoenix_channels_python_client-0.2.4.dist-info/METADATA +0 -269
- phoenix_channels_python_client-0.2.4.dist-info/RECORD +0 -17
- {phoenix_channels_python_client-0.2.4.dist-info → phoenix_channels_python_client-0.3.0.dist-info}/WHEEL +0 -0
- {phoenix_channels_python_client-0.2.4.dist-info → phoenix_channels_python_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {phoenix_channels_python_client-0.2.4.dist-info → phoenix_channels_python_client-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Phoenix Channels Python Client
|
|
1
|
+
"""Phoenix Channels Python Client.
|
|
3
2
|
|
|
4
3
|
A Python client library for connecting to Phoenix Channels.
|
|
5
4
|
"""
|
|
@@ -12,14 +11,15 @@ from phoenix_channels_python_client.client import (
|
|
|
12
11
|
PHXChannelsClient,
|
|
13
12
|
ReconnectCallback,
|
|
14
13
|
ReconnectPolicy,
|
|
14
|
+
TopicLostCallback,
|
|
15
15
|
)
|
|
16
16
|
from phoenix_channels_python_client.protocol_handler import (
|
|
17
|
-
PHXProtocolHandler,
|
|
18
17
|
PhoenixChannelsProtocolVersion,
|
|
18
|
+
PHXProtocolHandler,
|
|
19
19
|
)
|
|
20
20
|
from phoenix_channels_python_client.utils import setup_logging
|
|
21
21
|
|
|
22
|
-
__version__ = "0.
|
|
22
|
+
__version__ = "0.3.0"
|
|
23
23
|
__author__ = "Phoenix Channels Python Client"
|
|
24
24
|
|
|
25
25
|
__all__ = [
|
|
@@ -30,5 +30,6 @@ __all__ = [
|
|
|
30
30
|
"PhoenixChannelsProtocolVersion",
|
|
31
31
|
"ReconnectCallback",
|
|
32
32
|
"ReconnectPolicy",
|
|
33
|
+
"TopicLostCallback",
|
|
33
34
|
"setup_logging",
|
|
34
35
|
]
|
|
@@ -5,26 +5,24 @@ import logging
|
|
|
5
5
|
from collections import deque
|
|
6
6
|
from collections.abc import Awaitable, Callable
|
|
7
7
|
from types import TracebackType
|
|
8
|
+
from typing import Self
|
|
8
9
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
9
10
|
|
|
10
11
|
from websockets import ClientConnection
|
|
11
12
|
|
|
12
13
|
from phoenix_channels_python_client.client_state_machine import transition_client_state
|
|
13
|
-
from phoenix_channels_python_client.client_types import
|
|
14
|
-
ClientState,
|
|
15
|
-
ReconnectPolicy,
|
|
16
|
-
reconnect_policy_is_invalid,
|
|
17
|
-
validate_reconnect_policy,
|
|
18
|
-
)
|
|
14
|
+
from phoenix_channels_python_client.client_types import ClientState, ReconnectPolicy
|
|
19
15
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
20
16
|
from phoenix_channels_python_client.protocol_handler import (
|
|
21
|
-
|
|
17
|
+
DEFAULT_PROTOCOL_VERSION,
|
|
22
18
|
PhoenixChannelsProtocolVersion,
|
|
19
|
+
PHXProtocolHandler,
|
|
23
20
|
)
|
|
24
21
|
from phoenix_channels_python_client.reconnect_controller import ReconnectControllerMixin
|
|
25
22
|
from phoenix_channels_python_client.supervisor import SupervisorMixin
|
|
26
23
|
from phoenix_channels_python_client.topic_runtime import TopicRuntimeMixin
|
|
27
24
|
from phoenix_channels_python_client.topic_subscription import TopicSubscription
|
|
25
|
+
from phoenix_channels_python_client.utils import cancel_and_wait
|
|
28
26
|
|
|
29
27
|
logger = logging.getLogger(__name__)
|
|
30
28
|
|
|
@@ -34,6 +32,7 @@ DisconnectCallback = Callable[[Exception | None], Awaitable[None]]
|
|
|
34
32
|
# on the message-routing hot path, so blocking work here stalls heartbeat
|
|
35
33
|
# sends and message routing for every connection.
|
|
36
34
|
HeartbeatAckCallback = Callable[[], None]
|
|
35
|
+
TopicLostCallback = Callable[[str, Exception], Awaitable[None]]
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
def _build_channel_socket_urls(
|
|
@@ -73,12 +72,13 @@ def _build_channel_socket_urls(
|
|
|
73
72
|
|
|
74
73
|
|
|
75
74
|
class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
|
|
76
|
-
def __init__(
|
|
75
|
+
def __init__( # noqa: PLR0913 # the public options
|
|
77
76
|
self,
|
|
78
77
|
websocket_url: str,
|
|
79
78
|
api_key: str,
|
|
80
79
|
*,
|
|
81
|
-
protocol_version: PhoenixChannelsProtocolVersion
|
|
80
|
+
protocol_version: PhoenixChannelsProtocolVersion
|
|
81
|
+
| str = DEFAULT_PROTOCOL_VERSION,
|
|
82
82
|
auto_reconnect: bool = True,
|
|
83
83
|
reconnect_policy: ReconnectPolicy | None = None,
|
|
84
84
|
join_timeout_s: float = 10.0,
|
|
@@ -89,8 +89,9 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
89
89
|
on_reconnect: ReconnectCallback | None = None,
|
|
90
90
|
on_disconnect: DisconnectCallback | None = None,
|
|
91
91
|
on_heartbeat_ack: HeartbeatAckCallback | None = None,
|
|
92
|
+
on_topic_lost: TopicLostCallback | None = None,
|
|
92
93
|
additional_headers: dict[str, str] | None = None,
|
|
93
|
-
):
|
|
94
|
+
) -> None:
|
|
94
95
|
self.logger = logger
|
|
95
96
|
|
|
96
97
|
if heartbeat_interval_s is not None and heartbeat_interval_s <= 0:
|
|
@@ -104,20 +105,12 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
104
105
|
raise ValueError("max_topic_queue_size must be > 0")
|
|
105
106
|
if callback_drain_timeout_s <= 0:
|
|
106
107
|
raise ValueError("callback_drain_timeout_s must be > 0")
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
except ValueError as exc:
|
|
110
|
-
raise ValueError("Invalid reconnect policy configuration") from exc
|
|
111
|
-
|
|
112
|
-
vsn = (
|
|
113
|
-
"2.0.0"
|
|
114
|
-
if protocol_version == PhoenixChannelsProtocolVersion.V2
|
|
115
|
-
else "1.0.0"
|
|
116
|
-
)
|
|
108
|
+
|
|
109
|
+
self._protocol_handler = PHXProtocolHandler(protocol_version)
|
|
117
110
|
connect_url, redacted_url = _build_channel_socket_urls(
|
|
118
111
|
websocket_url=websocket_url,
|
|
119
112
|
api_key=api_key,
|
|
120
|
-
vsn=vsn,
|
|
113
|
+
vsn=self._protocol_handler.protocol_version.vsn,
|
|
121
114
|
)
|
|
122
115
|
self.channel_socket_url = connect_url
|
|
123
116
|
self.channel_socket_url_redacted = redacted_url
|
|
@@ -137,7 +130,6 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
137
130
|
|
|
138
131
|
self.connection: ClientConnection | None = None
|
|
139
132
|
self._topic_subscriptions: dict[str, TopicSubscription] = {}
|
|
140
|
-
self._protocol_handler = PHXProtocolHandler(protocol_version)
|
|
141
133
|
self._ref_counter = 0
|
|
142
134
|
self._state = ClientState.CLOSED
|
|
143
135
|
self._topics_lock = asyncio.Lock()
|
|
@@ -145,6 +137,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
145
137
|
self._connected_event = asyncio.Event()
|
|
146
138
|
self._conn_generation = 0
|
|
147
139
|
self._supervisor_task: asyncio.Task[None] | None = None
|
|
140
|
+
self._shutdown_task: asyncio.Task[None] | None = None
|
|
148
141
|
self._message_routing_task: asyncio.Task[None] | None = None
|
|
149
142
|
self._initial_connection_future: asyncio.Future[None] | None = None
|
|
150
143
|
self._rapid_disconnects: deque[float] = deque()
|
|
@@ -155,21 +148,33 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
155
148
|
self._on_reconnect = on_reconnect
|
|
156
149
|
self._on_disconnect = on_disconnect
|
|
157
150
|
self._on_heartbeat_ack = on_heartbeat_ack
|
|
151
|
+
self._on_topic_lost = on_topic_lost
|
|
152
|
+
self._lifecycle_tasks: set[asyncio.Task[None]] = set()
|
|
158
153
|
self._forced_close_pending = False
|
|
159
154
|
|
|
160
|
-
@
|
|
161
|
-
def
|
|
162
|
-
|
|
155
|
+
@property
|
|
156
|
+
def _active_shutdown(self) -> asyncio.Task[None] | None:
|
|
157
|
+
task = self._shutdown_task
|
|
158
|
+
return task if task is not None and not task.done() else None
|
|
163
159
|
|
|
164
|
-
|
|
160
|
+
@property
|
|
161
|
+
def _is_fully_closed(self) -> bool:
|
|
162
|
+
return (
|
|
163
|
+
self._state == ClientState.CLOSED
|
|
164
|
+
and not self._topic_subscriptions
|
|
165
|
+
and self.connection is None
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def __aenter__(self) -> Self:
|
|
165
169
|
self.logger.debug("Entering PHXChannelsClient context")
|
|
166
|
-
if self._state != ClientState.CLOSED:
|
|
170
|
+
if self._state != ClientState.CLOSED or self._active_shutdown is not None:
|
|
167
171
|
raise PHXConnectionError("Client is already running")
|
|
168
172
|
|
|
169
173
|
self._shutdown_event.clear()
|
|
170
174
|
self._connected_event.clear()
|
|
171
175
|
self._rapid_disconnects.clear()
|
|
172
176
|
self._terminal_error = None
|
|
177
|
+
self._conn_generation = 0
|
|
173
178
|
self._initial_connection_future = asyncio.get_running_loop().create_future()
|
|
174
179
|
self._transition_state(ClientState.CONNECTING)
|
|
175
180
|
|
|
@@ -192,57 +197,58 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
192
197
|
self.logger.debug("Leaving PHXChannelsClient context")
|
|
193
198
|
await self.shutdown("Leaving PHXChannelsClient context")
|
|
194
199
|
|
|
195
|
-
async def shutdown(
|
|
196
|
-
self
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
self.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
|
|
200
|
+
async def shutdown(self, reason: str) -> None:
|
|
201
|
+
task = self._active_shutdown
|
|
202
|
+
if task is None:
|
|
203
|
+
if self._is_fully_closed:
|
|
204
|
+
return
|
|
205
|
+
task = self._shutdown_task = asyncio.create_task(self._shutdown(reason))
|
|
206
|
+
# Every caller waits for the same shutdown, and one caller's
|
|
207
|
+
# cancellation never aborts it.
|
|
208
|
+
await asyncio.shield(task)
|
|
209
|
+
|
|
210
|
+
async def _shutdown(self, reason: str) -> None:
|
|
206
211
|
self.logger.info("Event loop shutting down! reason=%s", reason)
|
|
207
212
|
|
|
208
213
|
if self._state not in (ClientState.SHUTTING_DOWN, ClientState.CLOSED):
|
|
209
214
|
self._transition_state(ClientState.SHUTTING_DOWN)
|
|
210
|
-
|
|
211
215
|
self._shutdown_event.set()
|
|
212
216
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
for topic in topics_to_unsubscribe
|
|
218
|
-
]
|
|
219
|
-
results = await asyncio.gather(*unsubscribe_tasks, return_exceptions=True)
|
|
220
|
-
|
|
221
|
-
for topic, result in zip(topics_to_unsubscribe, results, strict=False):
|
|
222
|
-
if isinstance(result, Exception):
|
|
223
|
-
self.logger.warning(
|
|
224
|
-
"Failed to unsubscribe from topic %s during shutdown: %s",
|
|
225
|
-
topic,
|
|
226
|
-
result,
|
|
227
|
-
)
|
|
228
|
-
|
|
229
|
-
if self._supervisor_task and not self._supervisor_task.done():
|
|
230
|
-
self._supervisor_task.cancel()
|
|
231
|
-
try:
|
|
232
|
-
await self._supervisor_task
|
|
233
|
-
except asyncio.CancelledError:
|
|
234
|
-
self.logger.debug("Supervisor task cancelled during shutdown")
|
|
235
|
-
|
|
217
|
+
await self._unsubscribe_all()
|
|
218
|
+
# Includes on_topic_lost callbacks still running for topics already gone.
|
|
219
|
+
await cancel_and_wait(*self._lifecycle_tasks)
|
|
220
|
+
await self._stop_supervisor()
|
|
236
221
|
await self._cleanup_connection()
|
|
237
222
|
self._connected_event.clear()
|
|
238
223
|
self._transition_state(ClientState.CLOSED)
|
|
239
224
|
|
|
225
|
+
async def _unsubscribe_all(self) -> None:
|
|
226
|
+
topics = list(self._topic_subscriptions)
|
|
227
|
+
results = await asyncio.gather(
|
|
228
|
+
*(
|
|
229
|
+
self.unsubscribe_from_topic(topic, _allow_disconnected=True)
|
|
230
|
+
for topic in topics
|
|
231
|
+
),
|
|
232
|
+
return_exceptions=True,
|
|
233
|
+
)
|
|
234
|
+
for topic, result in zip(topics, results, strict=True):
|
|
235
|
+
if isinstance(result, Exception):
|
|
236
|
+
self.logger.warning(
|
|
237
|
+
"Failed to unsubscribe from topic %s during shutdown: %s",
|
|
238
|
+
topic,
|
|
239
|
+
result,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async def _stop_supervisor(self) -> None:
|
|
243
|
+
# Cleared, so run_forever() after the stop reports the client isn't running.
|
|
244
|
+
supervisor, self._supervisor_task = self._supervisor_task, None
|
|
245
|
+
if supervisor is not None and not supervisor.done():
|
|
246
|
+
await cancel_and_wait(supervisor)
|
|
247
|
+
|
|
240
248
|
def _transition_state(self, new_state: ClientState) -> None:
|
|
241
249
|
if self._state == new_state:
|
|
242
250
|
return
|
|
243
251
|
|
|
244
252
|
transitioned_state = transition_client_state(self._state, new_state)
|
|
245
|
-
self.logger.debug(
|
|
246
|
-
"Client state transition: %s -> %s", self._state.value, new_state.value
|
|
247
|
-
)
|
|
253
|
+
self.logger.debug("Client state transition: %s -> %s", self._state, new_state)
|
|
248
254
|
self._state = transitioned_state
|
|
@@ -31,8 +31,6 @@ def transition_client_state(
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if new_state not in allowed_transitions[current]:
|
|
34
|
-
raise RuntimeError(
|
|
35
|
-
f"Invalid state transition {current.value} -> {new_state.value}"
|
|
36
|
-
)
|
|
34
|
+
raise RuntimeError(f"Invalid state transition {current} -> {new_state}")
|
|
37
35
|
|
|
38
36
|
return new_state
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from enum import
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from pydantic import (
|
|
8
|
+
BaseModel,
|
|
9
|
+
ConfigDict,
|
|
10
|
+
NonNegativeFloat,
|
|
11
|
+
NonNegativeInt,
|
|
12
|
+
PositiveFloat,
|
|
13
|
+
model_validator,
|
|
14
|
+
)
|
|
5
15
|
|
|
6
16
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
7
17
|
|
|
8
18
|
|
|
9
|
-
class ClientState(
|
|
19
|
+
class ClientState(StrEnum):
|
|
10
20
|
CONNECTING = "connecting"
|
|
11
21
|
CONNECTED = "connected"
|
|
12
22
|
RECONNECTING = "reconnecting"
|
|
@@ -14,28 +24,45 @@ class ClientState(Enum):
|
|
|
14
24
|
CLOSED = "closed"
|
|
15
25
|
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
# Each (low, high) pair of fields where high must not be below low.
|
|
28
|
+
_ORDERED_POLICY_FIELDS = (
|
|
29
|
+
("service_restart_min_delay_s", "service_restart_max_delay_s"),
|
|
30
|
+
("try_again_later_min_delay_s", "try_again_later_max_delay_s"),
|
|
31
|
+
("rapid_cooldown_base_s", "rapid_cooldown_max_s"),
|
|
32
|
+
("rapid_hold_down_jitter_low_ratio", "rapid_hold_down_jitter_high_ratio"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ReconnectPolicy(BaseModel):
|
|
37
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
38
|
+
|
|
39
|
+
base_delay_s: NonNegativeFloat = 0.5
|
|
40
|
+
factor: PositiveFloat = 2.0
|
|
41
|
+
max_delay_s: NonNegativeFloat = 30.0
|
|
42
|
+
stable_reset_s: PositiveFloat = 60.0
|
|
23
43
|
reconnect_on_normal_close: bool = False
|
|
24
44
|
policy_violation_is_terminal: bool = True
|
|
25
|
-
service_restart_min_delay_s:
|
|
26
|
-
service_restart_max_delay_s:
|
|
27
|
-
try_again_later_min_delay_s:
|
|
28
|
-
try_again_later_max_delay_s:
|
|
29
|
-
rapid_disconnect_uptime_s:
|
|
30
|
-
rapid_window_s:
|
|
31
|
-
rapid_first_min_delay_s:
|
|
32
|
-
rapid_second_min_delay_s:
|
|
33
|
-
rapid_cooldown_base_s:
|
|
34
|
-
rapid_cooldown_step_s:
|
|
35
|
-
rapid_cooldown_max_s:
|
|
36
|
-
rapid_suppress_disconnect_count:
|
|
37
|
-
rapid_hold_down_jitter_low_ratio:
|
|
38
|
-
rapid_hold_down_jitter_high_ratio:
|
|
45
|
+
service_restart_min_delay_s: NonNegativeFloat = 1.0
|
|
46
|
+
service_restart_max_delay_s: NonNegativeFloat = 5.0
|
|
47
|
+
try_again_later_min_delay_s: NonNegativeFloat = 30.0
|
|
48
|
+
try_again_later_max_delay_s: NonNegativeFloat = 60.0
|
|
49
|
+
rapid_disconnect_uptime_s: NonNegativeFloat = 5.0
|
|
50
|
+
rapid_window_s: PositiveFloat = 60.0
|
|
51
|
+
rapid_first_min_delay_s: NonNegativeFloat = 2.0
|
|
52
|
+
rapid_second_min_delay_s: NonNegativeFloat = 10.0
|
|
53
|
+
rapid_cooldown_base_s: NonNegativeFloat = 60.0
|
|
54
|
+
rapid_cooldown_step_s: NonNegativeFloat = 30.0
|
|
55
|
+
rapid_cooldown_max_s: NonNegativeFloat = 300.0
|
|
56
|
+
rapid_suppress_disconnect_count: NonNegativeInt = 10
|
|
57
|
+
rapid_hold_down_jitter_low_ratio: NonNegativeFloat = 0.25
|
|
58
|
+
rapid_hold_down_jitter_high_ratio: NonNegativeFloat = 1.0
|
|
59
|
+
|
|
60
|
+
@model_validator(mode="after")
|
|
61
|
+
def _check_ordering(self) -> Self:
|
|
62
|
+
for low, high in _ORDERED_POLICY_FIELDS:
|
|
63
|
+
if getattr(self, high) < getattr(self, low):
|
|
64
|
+
raise ValueError(f"{high} must be >= {low}")
|
|
65
|
+
return self
|
|
39
66
|
|
|
40
67
|
|
|
41
68
|
@dataclass(frozen=True)
|
|
@@ -44,59 +71,3 @@ class ReconnectDecision:
|
|
|
44
71
|
min_delay_s: float | None = None
|
|
45
72
|
max_delay_s: float | None = None
|
|
46
73
|
terminal_error: PHXConnectionError | None = None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def validate_reconnect_policy(policy: ReconnectPolicy) -> None:
|
|
50
|
-
if policy.base_delay_s < 0:
|
|
51
|
-
raise ValueError("base_delay_s must be >= 0")
|
|
52
|
-
if policy.factor <= 0:
|
|
53
|
-
raise ValueError("factor must be > 0")
|
|
54
|
-
if policy.max_delay_s < 0:
|
|
55
|
-
raise ValueError("max_delay_s must be >= 0")
|
|
56
|
-
if policy.stable_reset_s <= 0:
|
|
57
|
-
raise ValueError("stable_reset_s must be > 0")
|
|
58
|
-
if policy.service_restart_min_delay_s < 0:
|
|
59
|
-
raise ValueError("service_restart_min_delay_s must be >= 0")
|
|
60
|
-
if policy.service_restart_max_delay_s < policy.service_restart_min_delay_s:
|
|
61
|
-
raise ValueError(
|
|
62
|
-
"service_restart_max_delay_s must be >= service_restart_min_delay_s"
|
|
63
|
-
)
|
|
64
|
-
if policy.try_again_later_min_delay_s < 0:
|
|
65
|
-
raise ValueError("try_again_later_min_delay_s must be >= 0")
|
|
66
|
-
if policy.try_again_later_max_delay_s < policy.try_again_later_min_delay_s:
|
|
67
|
-
raise ValueError(
|
|
68
|
-
"try_again_later_max_delay_s must be >= try_again_later_min_delay_s"
|
|
69
|
-
)
|
|
70
|
-
if policy.rapid_disconnect_uptime_s < 0:
|
|
71
|
-
raise ValueError("rapid_disconnect_uptime_s must be >= 0")
|
|
72
|
-
if policy.rapid_window_s <= 0:
|
|
73
|
-
raise ValueError("rapid_window_s must be > 0")
|
|
74
|
-
if policy.rapid_first_min_delay_s < 0:
|
|
75
|
-
raise ValueError("rapid_first_min_delay_s must be >= 0")
|
|
76
|
-
if policy.rapid_second_min_delay_s < 0:
|
|
77
|
-
raise ValueError("rapid_second_min_delay_s must be >= 0")
|
|
78
|
-
if policy.rapid_cooldown_base_s < 0:
|
|
79
|
-
raise ValueError("rapid_cooldown_base_s must be >= 0")
|
|
80
|
-
if policy.rapid_cooldown_step_s < 0:
|
|
81
|
-
raise ValueError("rapid_cooldown_step_s must be >= 0")
|
|
82
|
-
if policy.rapid_cooldown_max_s < policy.rapid_cooldown_base_s:
|
|
83
|
-
raise ValueError("rapid_cooldown_max_s must be >= rapid_cooldown_base_s")
|
|
84
|
-
if policy.rapid_suppress_disconnect_count < 0:
|
|
85
|
-
raise ValueError("rapid_suppress_disconnect_count must be >= 0")
|
|
86
|
-
if policy.rapid_hold_down_jitter_low_ratio < 0:
|
|
87
|
-
raise ValueError("rapid_hold_down_jitter_low_ratio must be >= 0")
|
|
88
|
-
if (
|
|
89
|
-
policy.rapid_hold_down_jitter_high_ratio
|
|
90
|
-
< policy.rapid_hold_down_jitter_low_ratio
|
|
91
|
-
):
|
|
92
|
-
raise ValueError(
|
|
93
|
-
"rapid_hold_down_jitter_high_ratio must be >= rapid_hold_down_jitter_low_ratio"
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
|
|
98
|
-
try:
|
|
99
|
-
validate_reconnect_policy(policy)
|
|
100
|
-
except ValueError:
|
|
101
|
-
return True
|
|
102
|
-
return False
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass
|
|
4
3
|
from enum import Enum, unique
|
|
5
4
|
from functools import cached_property
|
|
6
|
-
from typing import Any, NewType
|
|
5
|
+
from typing import Annotated, Any, NewType
|
|
7
6
|
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
8
|
|
|
9
9
|
PHOENIX_TOPIC = "phoenix"
|
|
10
10
|
|
|
@@ -21,16 +21,25 @@ class PHXEvent(Enum):
|
|
|
21
21
|
return self.value
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
# The client handles these itself: a crash rejoins, a close loses the topic.
|
|
25
|
+
CLIENT_LIFECYCLE_EVENTS = frozenset({PHXEvent.error, PHXEvent.close})
|
|
26
|
+
|
|
24
27
|
UserEvent = NewType("UserEvent", str)
|
|
25
28
|
# Compatibility alias for existing imports and call sites.
|
|
26
29
|
Event = UserEvent
|
|
30
|
+
# Not a PHXEvent member: that would reroute make_message and change a public enum.
|
|
31
|
+
HEARTBEAT_EVENT = Event("heartbeat")
|
|
27
32
|
ChannelEvent = PHXEvent | UserEvent
|
|
28
33
|
|
|
34
|
+
# Servers may send a ref as a JSON number; the client matches refs as strings.
|
|
35
|
+
Ref = Annotated[str | None, Field(coerce_numbers_to_str=True)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BasePHXMessage(BaseModel):
|
|
39
|
+
model_config = ConfigDict(frozen=True)
|
|
29
40
|
|
|
30
|
-
@dataclass(frozen=True)
|
|
31
|
-
class BasePHXMessage:
|
|
32
41
|
topic: str
|
|
33
|
-
ref:
|
|
42
|
+
ref: Ref
|
|
34
43
|
payload: dict[str, Any]
|
|
35
44
|
|
|
36
45
|
@cached_property
|
|
@@ -41,16 +50,14 @@ class BasePHXMessage:
|
|
|
41
50
|
return subtopic
|
|
42
51
|
|
|
43
52
|
|
|
44
|
-
@dataclass(frozen=True)
|
|
45
53
|
class PHXMessage(BasePHXMessage):
|
|
46
54
|
event: Event
|
|
47
|
-
join_ref:
|
|
55
|
+
join_ref: Ref = None
|
|
48
56
|
|
|
49
57
|
|
|
50
|
-
@dataclass(frozen=True)
|
|
51
58
|
class PHXEventMessage(BasePHXMessage):
|
|
52
59
|
event: PHXEvent
|
|
53
|
-
join_ref:
|
|
60
|
+
join_ref: Ref = None
|
|
54
61
|
|
|
55
62
|
|
|
56
63
|
ChannelMessage = PHXMessage | PHXEventMessage
|