phoenix-channels-python-client 0.4.0__tar.gz → 0.4.1__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.4.0 → phoenix_channels_python_client-0.4.1}/PKG-INFO +20 -3
- phoenix_channels_python_client-0.4.0/phoenix_channels_python_client.egg-info/PKG-INFO → phoenix_channels_python_client-0.4.1/README.md +17 -33
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/__init__.py +1 -1
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client.py +20 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/supervisor.py +85 -42
- phoenix_channels_python_client-0.4.0/README.md → phoenix_channels_python_client-0.4.1/phoenix_channels_python_client.egg-info/PKG-INFO +50 -2
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +1 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/pyproject.toml +3 -1
- phoenix_channels_python_client-0.4.1/tests/test_heartbeat_timeout.py +717 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_shutdown_races.py +14 -6
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/LICENSE +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/authentication.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/client_types.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/diagnostics.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/phx_messages.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/reconnect_controller.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/shutdown_signals.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/topic_runtime.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/topic_subscription.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client/utils.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_channel_recovery.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_client_state.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_connection.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_heartbeat.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect_policy_invariants.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_reconnect_stress.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_run_forever_signals.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_security.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_subscriptions.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/tests/test_supervisor_internals.py +0 -0
- {phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/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.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
5
|
Author: Phoenix Channels Python Client
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,7 @@ Requires-Dist: websockets>=14.2
|
|
|
19
19
|
Provides-Extra: dev
|
|
20
20
|
Requires-Dist: pytest; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
22
|
+
Requires-Dist: looptime<0.8,>=0.7; extra == "dev"
|
|
22
23
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
23
24
|
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
24
25
|
Requires-Dist: commitizen>=3.13.0; extra == "dev"
|
|
@@ -27,6 +28,7 @@ Requires-Dist: ruff; extra == "dev"
|
|
|
27
28
|
Provides-Extra: test
|
|
28
29
|
Requires-Dist: pytest; extra == "test"
|
|
29
30
|
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
31
|
+
Requires-Dist: looptime<0.8,>=0.7; extra == "test"
|
|
30
32
|
Dynamic: license-file
|
|
31
33
|
|
|
32
34
|
# Phoenix Channels Python Client
|
|
@@ -295,7 +297,8 @@ Everything after `api_key` is keyword-only:
|
|
|
295
297
|
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
296
298
|
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
297
299
|
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
298
|
-
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
|
|
300
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats and timeout enforcement |
|
|
301
|
+
| `heartbeat_dead_threshold_s` | `None` | Optional finite positive acknowledgement-silence limit, greater than the enabled finite interval |
|
|
299
302
|
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
300
303
|
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
301
304
|
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
@@ -346,7 +349,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
|
|
|
346
349
|
|
|
347
350
|
### Heartbeats
|
|
348
351
|
|
|
349
|
-
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`.
|
|
352
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. By default, missed replies log warnings and sending continues with a new reference each interval.
|
|
353
|
+
|
|
354
|
+
Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
client = PHXChannelsClient(
|
|
358
|
+
websocket_url, api_key,
|
|
359
|
+
heartbeat_interval_s=30.0,
|
|
360
|
+
heartbeat_dead_threshold_s=60.0,
|
|
361
|
+
)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The deadline starts when each socket is adopted, including during rejoin and reconnect callbacks. Only a matching pending reply received strictly before expiry renews it, before `on_heartbeat_ack` runs. Enabled enforcement keeps one outstanding heartbeat, skipping sends until it is acknowledged or expires. WebSocket Pong and unrelated messages do not renew it. Rejoin completion grants no additional grace.
|
|
365
|
+
|
|
366
|
+
Expiry or heartbeat send failure force-closes that socket; the supervisor handles cleanup and reconnect/rejoin according to `auto_reconnect` and the existing policy. The deadline bounds close initiation; the transport has its own close-handshake timeout. `heartbeat_interval_s=None` disables heartbeat activity even with a threshold, but a supplied threshold must still be finite and positive. Long thresholds do not prevent the server from applying its own inactivity timeout.
|
|
350
367
|
|
|
351
368
|
## Logging
|
|
352
369
|
|
|
@@ -1,34 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.4.0
|
|
4
|
-
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
|
-
Author: Phoenix Channels Python Client
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Classifier: Development Status :: 3 - Alpha
|
|
8
|
-
Classifier: Intended Audience :: Developers
|
|
9
|
-
Classifier: Operating System :: OS Independent
|
|
10
|
-
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
Requires-Python: >=3.11
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
License-File: LICENSE
|
|
17
|
-
Requires-Dist: pydantic>=2.8
|
|
18
|
-
Requires-Dist: websockets>=14.2
|
|
19
|
-
Provides-Extra: dev
|
|
20
|
-
Requires-Dist: pytest; extra == "dev"
|
|
21
|
-
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
22
|
-
Requires-Dist: pytest-cov; extra == "dev"
|
|
23
|
-
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
24
|
-
Requires-Dist: commitizen>=3.13.0; extra == "dev"
|
|
25
|
-
Requires-Dist: pyrefly; extra == "dev"
|
|
26
|
-
Requires-Dist: ruff; extra == "dev"
|
|
27
|
-
Provides-Extra: test
|
|
28
|
-
Requires-Dist: pytest; extra == "test"
|
|
29
|
-
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
30
|
-
Dynamic: license-file
|
|
31
|
-
|
|
32
1
|
# Phoenix Channels Python Client
|
|
33
2
|
|
|
34
3
|
[](https://pypi.org/project/phoenix-channels-python-client/)
|
|
@@ -295,7 +264,8 @@ Everything after `api_key` is keyword-only:
|
|
|
295
264
|
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
296
265
|
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
297
266
|
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
298
|
-
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
|
|
267
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats and timeout enforcement |
|
|
268
|
+
| `heartbeat_dead_threshold_s` | `None` | Optional finite positive acknowledgement-silence limit, greater than the enabled finite interval |
|
|
299
269
|
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
300
270
|
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
301
271
|
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
@@ -346,7 +316,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
|
|
|
346
316
|
|
|
347
317
|
### Heartbeats
|
|
348
318
|
|
|
349
|
-
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`.
|
|
319
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. By default, missed replies log warnings and sending continues with a new reference each interval.
|
|
320
|
+
|
|
321
|
+
Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
client = PHXChannelsClient(
|
|
325
|
+
websocket_url, api_key,
|
|
326
|
+
heartbeat_interval_s=30.0,
|
|
327
|
+
heartbeat_dead_threshold_s=60.0,
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The deadline starts when each socket is adopted, including during rejoin and reconnect callbacks. Only a matching pending reply received strictly before expiry renews it, before `on_heartbeat_ack` runs. Enabled enforcement keeps one outstanding heartbeat, skipping sends until it is acknowledged or expires. WebSocket Pong and unrelated messages do not renew it. Rejoin completion grants no additional grace.
|
|
332
|
+
|
|
333
|
+
Expiry or heartbeat send failure force-closes that socket; the supervisor handles cleanup and reconnect/rejoin according to `auto_reconnect` and the existing policy. The deadline bounds close initiation; the transport has its own close-handshake timeout. `heartbeat_interval_s=None` disables heartbeat activity even with a threshold, but a supplied threshold must still be finite and positive. Long thresholds do not prevent the server from applying its own inactivity timeout.
|
|
350
334
|
|
|
351
335
|
## Logging
|
|
352
336
|
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import math
|
|
5
6
|
from collections import deque
|
|
6
7
|
from collections.abc import Awaitable, Callable
|
|
7
8
|
from types import TracebackType
|
|
@@ -99,6 +100,19 @@ def _build_channel_socket_urls(
|
|
|
99
100
|
)
|
|
100
101
|
|
|
101
102
|
|
|
103
|
+
def _validate_heartbeat_threshold(
|
|
104
|
+
interval: float | None, threshold: float | None
|
|
105
|
+
) -> None:
|
|
106
|
+
if threshold is None:
|
|
107
|
+
return
|
|
108
|
+
if not math.isfinite(threshold) or threshold <= 0:
|
|
109
|
+
raise ValueError("heartbeat_dead_threshold_s must be finite and > 0")
|
|
110
|
+
if interval is not None and (not math.isfinite(interval) or threshold <= interval):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"heartbeat_dead_threshold_s must exceed the finite heartbeat interval"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
102
116
|
class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
|
|
103
117
|
def __init__( # noqa: PLR0913 # the public options
|
|
104
118
|
self,
|
|
@@ -114,6 +128,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
114
128
|
max_topic_queue_size: int = 1000,
|
|
115
129
|
callback_drain_timeout_s: float = 2.0,
|
|
116
130
|
heartbeat_interval_s: float | None = 30.0,
|
|
131
|
+
heartbeat_dead_threshold_s: float | None = None,
|
|
117
132
|
on_reconnect: ReconnectCallback | None = None,
|
|
118
133
|
on_disconnect: DisconnectCallback | None = None,
|
|
119
134
|
on_heartbeat_ack: HeartbeatAckCallback | None = None,
|
|
@@ -128,6 +143,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
128
143
|
if heartbeat_interval_s is not None and heartbeat_interval_s <= 0:
|
|
129
144
|
raise ValueError("heartbeat_interval_s must be > 0 or None to disable")
|
|
130
145
|
|
|
146
|
+
_validate_heartbeat_threshold(heartbeat_interval_s, heartbeat_dead_threshold_s)
|
|
147
|
+
|
|
131
148
|
if join_timeout_s <= 0:
|
|
132
149
|
raise ValueError("join_timeout_s must be > 0")
|
|
133
150
|
if leave_timeout_s <= 0:
|
|
@@ -170,6 +187,9 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
170
187
|
self._initial_connection_future: asyncio.Future[None] | None = None
|
|
171
188
|
self._rapid_disconnects: deque[float] = deque()
|
|
172
189
|
self._terminal_error: Exception | None = None
|
|
190
|
+
self._heartbeat_dead_threshold_s = heartbeat_dead_threshold_s
|
|
191
|
+
self._heartbeat_deadline: float | None = None
|
|
192
|
+
self._heartbeat_timeout: asyncio.Timeout | None = None
|
|
173
193
|
self._heartbeat_interval_s = heartbeat_interval_s
|
|
174
194
|
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
175
195
|
self._pending_heartbeat_ref: str | None = None
|
|
@@ -94,6 +94,9 @@ class SupervisorMixin:
|
|
|
94
94
|
_initial_connection_future: asyncio.Future[None] | None
|
|
95
95
|
_rapid_disconnects: deque[float]
|
|
96
96
|
_terminal_error: Exception | None
|
|
97
|
+
_heartbeat_dead_threshold_s: float | None
|
|
98
|
+
_heartbeat_deadline: float | None
|
|
99
|
+
_heartbeat_timeout: asyncio.Timeout | None
|
|
97
100
|
_heartbeat_interval_s: float | None
|
|
98
101
|
_heartbeat_task: asyncio.Task[None] | None
|
|
99
102
|
_pending_heartbeat_ref: str | None
|
|
@@ -118,6 +121,17 @@ class SupervisorMixin:
|
|
|
118
121
|
|
|
119
122
|
def _handle_heartbeat_response(self, message: ChannelMessage) -> None:
|
|
120
123
|
if message.ref is not None and message.ref == self._pending_heartbeat_ref:
|
|
124
|
+
if self._heartbeat_dead_threshold_s is not None:
|
|
125
|
+
timer = self._heartbeat_timeout
|
|
126
|
+
now = asyncio.get_running_loop().time()
|
|
127
|
+
if (
|
|
128
|
+
timer is None
|
|
129
|
+
or timer.expired()
|
|
130
|
+
or (deadline := timer.when()) is None
|
|
131
|
+
or now >= deadline
|
|
132
|
+
):
|
|
133
|
+
return
|
|
134
|
+
timer.reschedule(now + self._heartbeat_dead_threshold_s)
|
|
121
135
|
self.logger.debug("Heartbeat acknowledged (ref=%s)", message.ref)
|
|
122
136
|
self._pending_heartbeat_ref = None
|
|
123
137
|
if self._on_heartbeat_ack is not None:
|
|
@@ -149,6 +163,11 @@ class SupervisorMixin:
|
|
|
149
163
|
connection = self.connection
|
|
150
164
|
if connection is None:
|
|
151
165
|
return
|
|
166
|
+
await self._force_close_connection(connection, reason)
|
|
167
|
+
|
|
168
|
+
async def _force_close_connection(
|
|
169
|
+
self, connection: ClientConnection, reason: str
|
|
170
|
+
) -> None:
|
|
152
171
|
if connection.state is not State.OPEN:
|
|
153
172
|
self.logger.debug(
|
|
154
173
|
"Not forcing a close; connection is already %s", connection.state.name
|
|
@@ -157,52 +176,67 @@ class SupervisorMixin:
|
|
|
157
176
|
reason = _truncate_close_reason(reason)
|
|
158
177
|
self.logger.info("Forcing connection close: %s", reason)
|
|
159
178
|
self._forced_close_pending = True
|
|
160
|
-
await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
|
|
161
|
-
|
|
162
|
-
async def _heartbeat_loop(self, connection: ClientConnection) -> None:
|
|
163
|
-
if self._heartbeat_interval_s is None:
|
|
164
|
-
return
|
|
165
|
-
|
|
166
|
-
self.logger.debug(
|
|
167
|
-
"Starting heartbeat loop (interval=%ss)", self._heartbeat_interval_s
|
|
168
|
-
)
|
|
169
|
-
|
|
170
179
|
try:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
await connection.close(code=_FORCED_CLOSE_CODE, reason=reason)
|
|
181
|
+
except (Exception, asyncio.CancelledError):
|
|
182
|
+
connection.transport.abort()
|
|
183
|
+
raise
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
185
|
+
async def _heartbeat_loop(
|
|
186
|
+
self, connection: ClientConnection, deadline: float | None = None
|
|
187
|
+
) -> None:
|
|
188
|
+
timer = asyncio.timeout_at(deadline)
|
|
189
|
+
try:
|
|
190
|
+
async with timer:
|
|
191
|
+
self._heartbeat_timeout = timer
|
|
192
|
+
try:
|
|
193
|
+
await self._send_heartbeats(connection)
|
|
194
|
+
finally:
|
|
195
|
+
self._heartbeat_timeout = None
|
|
196
|
+
except TimeoutError:
|
|
197
|
+
if timer.expired():
|
|
198
|
+
self.logger.warning("Heartbeat acknowledgement deadline expired")
|
|
199
|
+
elif self._heartbeat_dead_threshold_s is not None:
|
|
200
|
+
self.logger.exception("Heartbeat send timed out")
|
|
201
|
+
except Exception:
|
|
202
|
+
if self._heartbeat_dead_threshold_s is None:
|
|
203
|
+
self.logger.debug("Failed to send heartbeat; connection likely closing")
|
|
204
|
+
return
|
|
205
|
+
self.logger.exception("Failed to send heartbeat")
|
|
206
|
+
else:
|
|
207
|
+
return
|
|
182
208
|
|
|
183
|
-
|
|
184
|
-
|
|
209
|
+
if self._heartbeat_dead_threshold_s is not None:
|
|
210
|
+
try:
|
|
211
|
+
await self._force_close_connection(connection, "Heartbeat failed")
|
|
212
|
+
except Exception:
|
|
213
|
+
self.logger.exception("Failed to close heartbeat connection")
|
|
185
214
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
215
|
+
async def _send_heartbeats(self, connection: ClientConnection) -> None:
|
|
216
|
+
interval = self._heartbeat_interval_s
|
|
217
|
+
if interval is None:
|
|
218
|
+
return
|
|
219
|
+
while not self._shutdown_event.is_set():
|
|
220
|
+
await self._wait_for_shutdown_or_timeout(interval)
|
|
221
|
+
if self._shutdown_event.is_set():
|
|
222
|
+
return
|
|
223
|
+
if self._pending_heartbeat_ref is not None:
|
|
224
|
+
if self._heartbeat_dead_threshold_s is not None:
|
|
225
|
+
continue
|
|
226
|
+
self.logger.warning(
|
|
227
|
+
"Heartbeat response not received for ref=%s; server may be "
|
|
228
|
+
"unresponsive",
|
|
229
|
+
self._pending_heartbeat_ref,
|
|
191
230
|
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
)
|
|
202
|
-
break
|
|
203
|
-
except asyncio.CancelledError:
|
|
204
|
-
self.logger.debug("Heartbeat loop cancelled")
|
|
205
|
-
raise
|
|
231
|
+
ref = self._deps._generate_ref()
|
|
232
|
+
self._pending_heartbeat_ref = ref
|
|
233
|
+
await self._protocol_handler.send_message(
|
|
234
|
+
connection,
|
|
235
|
+
make_message(
|
|
236
|
+
topic=PHOENIX_TOPIC, event=HEARTBEAT_EVENT, payload={}, ref=ref
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
self.logger.debug("Sent heartbeat (ref=%s)", ref)
|
|
206
240
|
|
|
207
241
|
async def _supervisor_loop(self) -> None:
|
|
208
242
|
attempt = 0
|
|
@@ -284,6 +318,12 @@ class SupervisorMixin:
|
|
|
284
318
|
# shutdown() began during the handshake; its own cleanup closes
|
|
285
319
|
# this socket.
|
|
286
320
|
return False
|
|
321
|
+
self._heartbeat_deadline = (
|
|
322
|
+
asyncio.get_running_loop().time() + self._heartbeat_dead_threshold_s
|
|
323
|
+
if self._heartbeat_interval_s is not None
|
|
324
|
+
and self._heartbeat_dead_threshold_s is not None
|
|
325
|
+
else None
|
|
326
|
+
)
|
|
287
327
|
self._conn_generation += 1
|
|
288
328
|
self._connected_event.set()
|
|
289
329
|
self._deps._transition_state(ClientState.CONNECTED)
|
|
@@ -299,7 +339,9 @@ class SupervisorMixin:
|
|
|
299
339
|
# a rejoin is still waiting on one, before cleanup runs.
|
|
300
340
|
routing.add_done_callback(self._fail_joins_when_routing_stops)
|
|
301
341
|
if self._heartbeat_interval_s is not None:
|
|
302
|
-
self._heartbeat_task = asyncio.create_task(
|
|
342
|
+
self._heartbeat_task = asyncio.create_task(
|
|
343
|
+
self._heartbeat_loop(connection, self._heartbeat_deadline)
|
|
344
|
+
)
|
|
303
345
|
|
|
304
346
|
try:
|
|
305
347
|
await self._deps._rejoin_topics(generation)
|
|
@@ -432,6 +474,7 @@ class SupervisorMixin:
|
|
|
432
474
|
]
|
|
433
475
|
self.connection = None
|
|
434
476
|
self._heartbeat_task = None
|
|
477
|
+
self._heartbeat_deadline = None
|
|
435
478
|
self._message_routing_task = None
|
|
436
479
|
self._pending_heartbeat_ref = None
|
|
437
480
|
self._connected_event.clear()
|
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phoenix-channels-python-client
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
|
+
Author: Phoenix Channels Python Client
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pydantic>=2.8
|
|
18
|
+
Requires-Dist: websockets>=14.2
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
22
|
+
Requires-Dist: looptime<0.8,>=0.7; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: commitizen>=3.13.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pyrefly; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest; extra == "test"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
31
|
+
Requires-Dist: looptime<0.8,>=0.7; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
1
34
|
# Phoenix Channels Python Client
|
|
2
35
|
|
|
3
36
|
[](https://pypi.org/project/phoenix-channels-python-client/)
|
|
@@ -264,7 +297,8 @@ Everything after `api_key` is keyword-only:
|
|
|
264
297
|
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
265
298
|
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
266
299
|
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
267
|
-
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
|
|
300
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats and timeout enforcement |
|
|
301
|
+
| `heartbeat_dead_threshold_s` | `None` | Optional finite positive acknowledgement-silence limit, greater than the enabled finite interval |
|
|
268
302
|
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
269
303
|
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
270
304
|
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
@@ -315,7 +349,21 @@ The client drops the topic and calls `on_topic_lost(topic, error)` once when the
|
|
|
315
349
|
|
|
316
350
|
### Heartbeats
|
|
317
351
|
|
|
318
|
-
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`.
|
|
352
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. By default, missed replies log warnings and sending continues with a new reference each interval.
|
|
353
|
+
|
|
354
|
+
Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
client = PHXChannelsClient(
|
|
358
|
+
websocket_url, api_key,
|
|
359
|
+
heartbeat_interval_s=30.0,
|
|
360
|
+
heartbeat_dead_threshold_s=60.0,
|
|
361
|
+
)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The deadline starts when each socket is adopted, including during rejoin and reconnect callbacks. Only a matching pending reply received strictly before expiry renews it, before `on_heartbeat_ack` runs. Enabled enforcement keeps one outstanding heartbeat, skipping sends until it is acknowledged or expires. WebSocket Pong and unrelated messages do not renew it. Rejoin completion grants no additional grace.
|
|
365
|
+
|
|
366
|
+
Expiry or heartbeat send failure force-closes that socket; the supervisor handles cleanup and reconnect/rejoin according to `auto_reconnect` and the existing policy. The deadline bounds close initiation; the transport has its own close-handshake timeout. `heartbeat_interval_s=None` disables heartbeat activity even with a threshold, but a supplied threshold must still be finite and positive. Long thresholds do not prevent the server from applying its own inactivity timeout.
|
|
319
367
|
|
|
320
368
|
## Logging
|
|
321
369
|
|
{phoenix_channels_python_client-0.4.0 → phoenix_channels_python_client-0.4.1}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "phoenix-channels-python-client"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "A Python client library for connecting to Phoenix Channels"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -28,6 +28,7 @@ classifiers = [
|
|
|
28
28
|
dev = [
|
|
29
29
|
"pytest",
|
|
30
30
|
"pytest-asyncio>=1.4",
|
|
31
|
+
"looptime>=0.7,<0.8",
|
|
31
32
|
"pytest-cov",
|
|
32
33
|
"pre-commit>=3.0.0",
|
|
33
34
|
"commitizen>=3.13.0",
|
|
@@ -37,6 +38,7 @@ dev = [
|
|
|
37
38
|
test = [
|
|
38
39
|
"pytest",
|
|
39
40
|
"pytest-asyncio>=1.4",
|
|
41
|
+
"looptime>=0.7,<0.8",
|
|
40
42
|
]
|
|
41
43
|
|
|
42
44
|
[tool.ruff.lint]
|