phoenix-channels-python-client 0.2.3__tar.gz → 0.2.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {phoenix_channels_python_client-0.2.3/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.2.5}/PKG-INFO +121 -23
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/README.md +118 -20
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/__init__.py +3 -1
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/client.py +59 -36
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/phx_messages.py +3 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/protocol_handler.py +6 -2
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/reconnect_controller.py +9 -5
- phoenix_channels_python_client-0.2.5/phoenix_channels_python_client/shutdown_signals.py +119 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/supervisor.py +140 -78
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/topic_runtime.py +4 -11
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/utils.py +19 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5/phoenix_channels_python_client.egg-info}/PKG-INFO +121 -23
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client.egg-info/SOURCES.txt +4 -1
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client.egg-info/requires.txt +2 -2
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/pyproject.toml +3 -3
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/tests/test_internal_components.py +220 -82
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/tests/test_reconnect_stress.py +1 -1
- phoenix_channels_python_client-0.2.5/tests/test_run_forever_signals.py +311 -0
- phoenix_channels_python_client-0.2.5/tests/test_shutdown_races.py +221 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/LICENSE +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/client_types.py +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/topic_subscription.py +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.2.3 → phoenix_channels_python_client-0.2.5}/tests/test_reconnect_policy_invariants.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
5
|
Author: Phoenix Channels Python Client
|
|
6
6
|
License-Expression: MIT
|
|
@@ -17,14 +17,14 @@ License-File: LICENSE
|
|
|
17
17
|
Requires-Dist: websockets>=14.2
|
|
18
18
|
Provides-Extra: dev
|
|
19
19
|
Requires-Dist: pytest; extra == "dev"
|
|
20
|
-
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
21
21
|
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
22
22
|
Requires-Dist: commitizen>=3.13.0; extra == "dev"
|
|
23
23
|
Requires-Dist: pyrefly; extra == "dev"
|
|
24
24
|
Requires-Dist: ruff; extra == "dev"
|
|
25
25
|
Provides-Extra: test
|
|
26
26
|
Requires-Dist: pytest; extra == "test"
|
|
27
|
-
Requires-Dist: pytest-asyncio; extra == "test"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
28
28
|
Dynamic: license-file
|
|
29
29
|
|
|
30
30
|
# Phoenix Channels Python Client
|
|
@@ -39,7 +39,11 @@ A modern, async Python client library for connecting to [Phoenix Channels](https
|
|
|
39
39
|
|
|
40
40
|
### For Users
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
```bash
|
|
43
|
+
pip install phoenix-channels-python-client
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or from source:
|
|
43
47
|
|
|
44
48
|
```bash
|
|
45
49
|
git clone https://github.com/band-ai/phoenix-channels-python-client.git
|
|
@@ -65,7 +69,6 @@ pip install -e ".[dev]"
|
|
|
65
69
|
Run tests:
|
|
66
70
|
|
|
67
71
|
```bash
|
|
68
|
-
pip install -e ".[test]"
|
|
69
72
|
pytest
|
|
70
73
|
```
|
|
71
74
|
|
|
@@ -96,26 +99,56 @@ async def main():
|
|
|
96
99
|
# Subscribe to a topic
|
|
97
100
|
await client.subscribe_to_topic("room:lobby", handle_message)
|
|
98
101
|
|
|
99
|
-
#
|
|
100
|
-
|
|
102
|
+
# Ctrl+C or SIGTERM shuts the client down and run_forever() returns.
|
|
103
|
+
# The previous handlers are restored first, so a second signal reaches them.
|
|
104
|
+
await client.run_forever()
|
|
101
105
|
|
|
102
106
|
if __name__ == "__main__":
|
|
103
107
|
asyncio.run(main())
|
|
104
108
|
```
|
|
105
109
|
|
|
106
|
-
|
|
110
|
+
### Owning process signals yourself
|
|
111
|
+
|
|
112
|
+
If your application owns its process signals (a service, desktop app or test
|
|
113
|
+
runner), call `client.run_forever(install_signal_handlers=False)`. It then
|
|
114
|
+
leaves signal handlers alone; stop the client by scheduling
|
|
115
|
+
`client.shutdown(reason)` on the client's event loop, and `run_forever()`
|
|
116
|
+
returns `None`:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
loop = asyncio.get_running_loop()
|
|
120
|
+
async with asyncio.TaskGroup() as tasks:
|
|
121
|
+
loop.add_signal_handler(
|
|
122
|
+
signal.SIGTERM, lambda: tasks.create_task(client.shutdown("SIGTERM"))
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
await client.run_forever(install_signal_handlers=False)
|
|
126
|
+
finally:
|
|
127
|
+
# Once the group exits, a later SIGTERM must not reach the closed group.
|
|
128
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
From a plain `signal.signal` handler or another thread, use
|
|
132
|
+
`loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
|
|
133
|
+
doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
|
|
134
|
+
network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
|
|
107
135
|
|
|
108
|
-
|
|
136
|
+
With the default, handlers are installed only when `run_forever()` runs on the
|
|
137
|
+
main thread; elsewhere it just waits. A handler you registered with
|
|
138
|
+
`loop.add_signal_handler` before calling `run_forever()` still fires for the
|
|
139
|
+
same signal; one registered while it runs replaces the client's.
|
|
109
140
|
|
|
110
|
-
|
|
111
|
-
- `phx_reply` - Server replies to client messages
|
|
112
|
-
- `phx_leave` - Channel leave notifications
|
|
113
|
-
- `phx_close` - Channel close events
|
|
114
|
-
- `phx_error` - Channel error events
|
|
141
|
+
## Phoenix System Events
|
|
115
142
|
|
|
116
|
-
|
|
143
|
+
Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
|
|
144
|
+
`phx_error`. Don't use these names for your own events.
|
|
117
145
|
|
|
118
|
-
|
|
146
|
+
- The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
|
|
147
|
+
- A server `phx_close` or `phx_error` reaches your message handler like any
|
|
148
|
+
other message; the client does not rejoin or unsubscribe on its own.
|
|
149
|
+
- For an event-specific handler on these, register `PHXEvent.close` or
|
|
150
|
+
`PHXEvent.error` from `phoenix_channels_python_client.phx_messages`; the
|
|
151
|
+
plain strings don't match.
|
|
119
152
|
|
|
120
153
|
## Protocol Versions
|
|
121
154
|
|
|
@@ -130,7 +163,7 @@ async with PHXChannelsClient(
|
|
|
130
163
|
api_key="your-api-key"
|
|
131
164
|
# protocol_version defaults to v2.0
|
|
132
165
|
) as client:
|
|
133
|
-
#
|
|
166
|
+
... # your code here
|
|
134
167
|
```
|
|
135
168
|
|
|
136
169
|
### Protocol v1.0
|
|
@@ -142,9 +175,11 @@ async with PHXChannelsClient(
|
|
|
142
175
|
api_key="your-api-key",
|
|
143
176
|
protocol_version=PhoenixChannelsProtocolVersion.V1
|
|
144
177
|
) as client:
|
|
145
|
-
#
|
|
178
|
+
... # your code here
|
|
146
179
|
```
|
|
147
180
|
|
|
181
|
+
The client adds `vsn=2.0.0` (V2) or `vsn=1.0.0` (V1) to the socket URL.
|
|
182
|
+
|
|
148
183
|
## Usage Examples
|
|
149
184
|
|
|
150
185
|
### Basic Topic Subscription
|
|
@@ -186,7 +221,7 @@ The library provides two complementary ways to handle incoming messages:
|
|
|
186
221
|
1. **Message handler runs first** (if set) - receives the full message
|
|
187
222
|
2. **Event-specific handler runs second** (if matching event) - receives just the payload
|
|
188
223
|
|
|
189
|
-
You can add, remove, or change either type of handler at any time during your connection.
|
|
224
|
+
All handlers must be `async def`. You can add, remove, or change either type of handler at any time during your connection.
|
|
190
225
|
|
|
191
226
|
### Event-Specific Handlers
|
|
192
227
|
|
|
@@ -225,10 +260,13 @@ You can unsubscribe from topics to stop receiving messages and clean up resource
|
|
|
225
260
|
import asyncio
|
|
226
261
|
from phoenix_channels_python_client import PHXChannelsClient
|
|
227
262
|
|
|
263
|
+
async def on_message(msg):
|
|
264
|
+
print(f"Message: {msg.payload}")
|
|
265
|
+
|
|
228
266
|
async def main():
|
|
229
267
|
async with PHXChannelsClient("ws://localhost:4000/socket/websocket", "api-key") as client:
|
|
230
268
|
# Subscribe to a topic
|
|
231
|
-
await client.subscribe_to_topic("room:lobby",
|
|
269
|
+
await client.subscribe_to_topic("room:lobby", on_message)
|
|
232
270
|
|
|
233
271
|
# Do some work...
|
|
234
272
|
await asyncio.sleep(5)
|
|
@@ -245,13 +283,73 @@ asyncio.run(main())
|
|
|
245
283
|
|
|
246
284
|
**Notes on unsubscription:**
|
|
247
285
|
- Removes all handlers (both message and event-specific) for that topic
|
|
248
|
-
- Sends
|
|
286
|
+
- Sends `phx_leave` and waits for the reply (`leave_timeout_s`, default 5 s)
|
|
287
|
+
- Raises `PHXTopicError` if the topic isn't subscribed or the leave times out, and `PHXConnectionError` while disconnected
|
|
288
|
+
|
|
289
|
+
## Configuration
|
|
290
|
+
|
|
291
|
+
Everything after `api_key` is keyword-only:
|
|
292
|
+
|
|
293
|
+
| Option | Default | Meaning |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
296
|
+
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
297
|
+
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
298
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
|
|
299
|
+
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
300
|
+
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
301
|
+
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
302
|
+
| `callback_drain_timeout_s` | `2.0` | How long a running callback may finish before a rejoin cancels it |
|
|
303
|
+
| `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
|
|
304
|
+
| `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
|
|
305
|
+
| `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
|
|
306
|
+
| `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
|
|
307
|
+
|
|
308
|
+
Exceptions raised in callbacks are logged, not raised.
|
|
309
|
+
|
|
310
|
+
## Connection Lifecycle and Errors
|
|
311
|
+
|
|
312
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
|
|
313
|
+
- `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`.
|
|
314
|
+
- `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription.
|
|
315
|
+
- `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on.
|
|
316
|
+
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
317
|
+
|
|
318
|
+
### Reconnects
|
|
319
|
+
|
|
320
|
+
| Close code | Behaviour (defaults) |
|
|
321
|
+
|---|---|
|
|
322
|
+
| 1000, 1001 | Stop, unless `reconnect_on_normal_close=True` |
|
|
323
|
+
| 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
|
|
324
|
+
| 1012 | Reconnect after at least a random 1–5 s |
|
|
325
|
+
| 1013 | Reconnect after at least a random 30–60 s |
|
|
326
|
+
| Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
|
|
327
|
+
|
|
328
|
+
Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `PHXConnectionError`. Tune all of this with `ReconnectPolicy`.
|
|
329
|
+
|
|
330
|
+
### Heartbeats
|
|
331
|
+
|
|
332
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
|
|
333
|
+
|
|
334
|
+
## Logging
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
import logging
|
|
338
|
+
from phoenix_channels_python_client import setup_logging
|
|
339
|
+
|
|
340
|
+
setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_client"
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Examples
|
|
344
|
+
|
|
345
|
+
- [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
|
|
346
|
+
- [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
|
|
249
347
|
|
|
250
348
|
---
|
|
251
349
|
|
|
252
350
|
## Security Considerations
|
|
253
351
|
|
|
254
|
-
**API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`).
|
|
352
|
+
**API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
|
|
255
353
|
|
|
256
354
|
- Server access logs
|
|
257
355
|
- Reverse proxies and load balancers
|
|
@@ -262,7 +360,7 @@ asyncio.run(main())
|
|
|
262
360
|
- Use short-lived, rotatable tokens rather than long-lived API keys
|
|
263
361
|
- Consider this limitation when evaluating this client for sensitive environments
|
|
264
362
|
|
|
265
|
-
**Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This
|
|
363
|
+
**Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
|
|
266
364
|
|
|
267
365
|
---
|
|
268
366
|
|
|
@@ -10,7 +10,11 @@ A modern, async Python client library for connecting to [Phoenix Channels](https
|
|
|
10
10
|
|
|
11
11
|
### For Users
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
```bash
|
|
14
|
+
pip install phoenix-channels-python-client
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or from source:
|
|
14
18
|
|
|
15
19
|
```bash
|
|
16
20
|
git clone https://github.com/band-ai/phoenix-channels-python-client.git
|
|
@@ -36,7 +40,6 @@ pip install -e ".[dev]"
|
|
|
36
40
|
Run tests:
|
|
37
41
|
|
|
38
42
|
```bash
|
|
39
|
-
pip install -e ".[test]"
|
|
40
43
|
pytest
|
|
41
44
|
```
|
|
42
45
|
|
|
@@ -67,26 +70,56 @@ async def main():
|
|
|
67
70
|
# Subscribe to a topic
|
|
68
71
|
await client.subscribe_to_topic("room:lobby", handle_message)
|
|
69
72
|
|
|
70
|
-
#
|
|
71
|
-
|
|
73
|
+
# Ctrl+C or SIGTERM shuts the client down and run_forever() returns.
|
|
74
|
+
# The previous handlers are restored first, so a second signal reaches them.
|
|
75
|
+
await client.run_forever()
|
|
72
76
|
|
|
73
77
|
if __name__ == "__main__":
|
|
74
78
|
asyncio.run(main())
|
|
75
79
|
```
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
### Owning process signals yourself
|
|
82
|
+
|
|
83
|
+
If your application owns its process signals (a service, desktop app or test
|
|
84
|
+
runner), call `client.run_forever(install_signal_handlers=False)`. It then
|
|
85
|
+
leaves signal handlers alone; stop the client by scheduling
|
|
86
|
+
`client.shutdown(reason)` on the client's event loop, and `run_forever()`
|
|
87
|
+
returns `None`:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
loop = asyncio.get_running_loop()
|
|
91
|
+
async with asyncio.TaskGroup() as tasks:
|
|
92
|
+
loop.add_signal_handler(
|
|
93
|
+
signal.SIGTERM, lambda: tasks.create_task(client.shutdown("SIGTERM"))
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
await client.run_forever(install_signal_handlers=False)
|
|
97
|
+
finally:
|
|
98
|
+
# Once the group exits, a later SIGTERM must not reach the closed group.
|
|
99
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
From a plain `signal.signal` handler or another thread, use
|
|
103
|
+
`loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
|
|
104
|
+
doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
|
|
105
|
+
network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
|
|
78
106
|
|
|
79
|
-
|
|
107
|
+
With the default, handlers are installed only when `run_forever()` runs on the
|
|
108
|
+
main thread; elsewhere it just waits. A handler you registered with
|
|
109
|
+
`loop.add_signal_handler` before calling `run_forever()` still fires for the
|
|
110
|
+
same signal; one registered while it runs replaces the client's.
|
|
80
111
|
|
|
81
|
-
|
|
82
|
-
- `phx_reply` - Server replies to client messages
|
|
83
|
-
- `phx_leave` - Channel leave notifications
|
|
84
|
-
- `phx_close` - Channel close events
|
|
85
|
-
- `phx_error` - Channel error events
|
|
112
|
+
## Phoenix System Events
|
|
86
113
|
|
|
87
|
-
|
|
114
|
+
Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
|
|
115
|
+
`phx_error`. Don't use these names for your own events.
|
|
88
116
|
|
|
89
|
-
|
|
117
|
+
- The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
|
|
118
|
+
- A server `phx_close` or `phx_error` reaches your message handler like any
|
|
119
|
+
other message; the client does not rejoin or unsubscribe on its own.
|
|
120
|
+
- For an event-specific handler on these, register `PHXEvent.close` or
|
|
121
|
+
`PHXEvent.error` from `phoenix_channels_python_client.phx_messages`; the
|
|
122
|
+
plain strings don't match.
|
|
90
123
|
|
|
91
124
|
## Protocol Versions
|
|
92
125
|
|
|
@@ -101,7 +134,7 @@ async with PHXChannelsClient(
|
|
|
101
134
|
api_key="your-api-key"
|
|
102
135
|
# protocol_version defaults to v2.0
|
|
103
136
|
) as client:
|
|
104
|
-
#
|
|
137
|
+
... # your code here
|
|
105
138
|
```
|
|
106
139
|
|
|
107
140
|
### Protocol v1.0
|
|
@@ -113,9 +146,11 @@ async with PHXChannelsClient(
|
|
|
113
146
|
api_key="your-api-key",
|
|
114
147
|
protocol_version=PhoenixChannelsProtocolVersion.V1
|
|
115
148
|
) as client:
|
|
116
|
-
#
|
|
149
|
+
... # your code here
|
|
117
150
|
```
|
|
118
151
|
|
|
152
|
+
The client adds `vsn=2.0.0` (V2) or `vsn=1.0.0` (V1) to the socket URL.
|
|
153
|
+
|
|
119
154
|
## Usage Examples
|
|
120
155
|
|
|
121
156
|
### Basic Topic Subscription
|
|
@@ -157,7 +192,7 @@ The library provides two complementary ways to handle incoming messages:
|
|
|
157
192
|
1. **Message handler runs first** (if set) - receives the full message
|
|
158
193
|
2. **Event-specific handler runs second** (if matching event) - receives just the payload
|
|
159
194
|
|
|
160
|
-
You can add, remove, or change either type of handler at any time during your connection.
|
|
195
|
+
All handlers must be `async def`. You can add, remove, or change either type of handler at any time during your connection.
|
|
161
196
|
|
|
162
197
|
### Event-Specific Handlers
|
|
163
198
|
|
|
@@ -196,10 +231,13 @@ You can unsubscribe from topics to stop receiving messages and clean up resource
|
|
|
196
231
|
import asyncio
|
|
197
232
|
from phoenix_channels_python_client import PHXChannelsClient
|
|
198
233
|
|
|
234
|
+
async def on_message(msg):
|
|
235
|
+
print(f"Message: {msg.payload}")
|
|
236
|
+
|
|
199
237
|
async def main():
|
|
200
238
|
async with PHXChannelsClient("ws://localhost:4000/socket/websocket", "api-key") as client:
|
|
201
239
|
# Subscribe to a topic
|
|
202
|
-
await client.subscribe_to_topic("room:lobby",
|
|
240
|
+
await client.subscribe_to_topic("room:lobby", on_message)
|
|
203
241
|
|
|
204
242
|
# Do some work...
|
|
205
243
|
await asyncio.sleep(5)
|
|
@@ -216,13 +254,73 @@ asyncio.run(main())
|
|
|
216
254
|
|
|
217
255
|
**Notes on unsubscription:**
|
|
218
256
|
- Removes all handlers (both message and event-specific) for that topic
|
|
219
|
-
- Sends
|
|
257
|
+
- Sends `phx_leave` and waits for the reply (`leave_timeout_s`, default 5 s)
|
|
258
|
+
- Raises `PHXTopicError` if the topic isn't subscribed or the leave times out, and `PHXConnectionError` while disconnected
|
|
259
|
+
|
|
260
|
+
## Configuration
|
|
261
|
+
|
|
262
|
+
Everything after `api_key` is keyword-only:
|
|
263
|
+
|
|
264
|
+
| Option | Default | Meaning |
|
|
265
|
+
|---|---|---|
|
|
266
|
+
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
267
|
+
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
268
|
+
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
269
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
|
|
270
|
+
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
271
|
+
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
272
|
+
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
273
|
+
| `callback_drain_timeout_s` | `2.0` | How long a running callback may finish before a rejoin cancels it |
|
|
274
|
+
| `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
|
|
275
|
+
| `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
|
|
276
|
+
| `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
|
|
277
|
+
| `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
|
|
278
|
+
|
|
279
|
+
Exceptions raised in callbacks are logged, not raised.
|
|
280
|
+
|
|
281
|
+
## Connection Lifecycle and Errors
|
|
282
|
+
|
|
283
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
|
|
284
|
+
- `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`.
|
|
285
|
+
- `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription.
|
|
286
|
+
- `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on.
|
|
287
|
+
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
288
|
+
|
|
289
|
+
### Reconnects
|
|
290
|
+
|
|
291
|
+
| Close code | Behaviour (defaults) |
|
|
292
|
+
|---|---|
|
|
293
|
+
| 1000, 1001 | Stop, unless `reconnect_on_normal_close=True` |
|
|
294
|
+
| 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
|
|
295
|
+
| 1012 | Reconnect after at least a random 1–5 s |
|
|
296
|
+
| 1013 | Reconnect after at least a random 30–60 s |
|
|
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 |
|
|
298
|
+
|
|
299
|
+
Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `PHXConnectionError`. Tune all of this with `ReconnectPolicy`.
|
|
300
|
+
|
|
301
|
+
### Heartbeats
|
|
302
|
+
|
|
303
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
|
|
304
|
+
|
|
305
|
+
## Logging
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
import logging
|
|
309
|
+
from phoenix_channels_python_client import setup_logging
|
|
310
|
+
|
|
311
|
+
setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_client"
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Examples
|
|
315
|
+
|
|
316
|
+
- [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
|
|
317
|
+
- [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
|
|
220
318
|
|
|
221
319
|
---
|
|
222
320
|
|
|
223
321
|
## Security Considerations
|
|
224
322
|
|
|
225
|
-
**API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`).
|
|
323
|
+
**API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
|
|
226
324
|
|
|
227
325
|
- Server access logs
|
|
228
326
|
- Reverse proxies and load balancers
|
|
@@ -233,7 +331,7 @@ asyncio.run(main())
|
|
|
233
331
|
- Use short-lived, rotatable tokens rather than long-lived API keys
|
|
234
332
|
- Consider this limitation when evaluating this client for sensitive environments
|
|
235
333
|
|
|
236
|
-
**Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This
|
|
334
|
+
**Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
|
|
237
335
|
|
|
238
336
|
---
|
|
239
337
|
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from phoenix_channels_python_client.client import (
|
|
10
10
|
DisconnectCallback,
|
|
11
|
+
HeartbeatAckCallback,
|
|
11
12
|
PHXChannelsClient,
|
|
12
13
|
ReconnectCallback,
|
|
13
14
|
ReconnectPolicy,
|
|
@@ -18,11 +19,12 @@ from phoenix_channels_python_client.protocol_handler import (
|
|
|
18
19
|
)
|
|
19
20
|
from phoenix_channels_python_client.utils import setup_logging
|
|
20
21
|
|
|
21
|
-
__version__ = "0.2.
|
|
22
|
+
__version__ = "0.2.5"
|
|
22
23
|
__author__ = "Phoenix Channels Python Client"
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"DisconnectCallback",
|
|
27
|
+
"HeartbeatAckCallback",
|
|
26
28
|
"PHXChannelsClient",
|
|
27
29
|
"PHXProtocolHandler",
|
|
28
30
|
"PhoenixChannelsProtocolVersion",
|
|
@@ -25,11 +25,16 @@ from phoenix_channels_python_client.reconnect_controller import ReconnectControl
|
|
|
25
25
|
from phoenix_channels_python_client.supervisor import SupervisorMixin
|
|
26
26
|
from phoenix_channels_python_client.topic_runtime import TopicRuntimeMixin
|
|
27
27
|
from phoenix_channels_python_client.topic_subscription import TopicSubscription
|
|
28
|
+
from phoenix_channels_python_client.utils import cancel_and_wait
|
|
28
29
|
|
|
29
30
|
logger = logging.getLogger(__name__)
|
|
30
31
|
|
|
31
32
|
ReconnectCallback = Callable[[], Awaitable[None]]
|
|
32
33
|
DisconnectCallback = Callable[[Exception | None], Awaitable[None]]
|
|
34
|
+
# Must be synchronous and fast: unlike the callbacks above, this runs inline
|
|
35
|
+
# on the message-routing hot path, so blocking work here stalls heartbeat
|
|
36
|
+
# sends and message routing for every connection.
|
|
37
|
+
HeartbeatAckCallback = Callable[[], None]
|
|
33
38
|
|
|
34
39
|
|
|
35
40
|
def _build_channel_socket_urls(
|
|
@@ -84,6 +89,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
84
89
|
heartbeat_interval_s: float | None = 30.0,
|
|
85
90
|
on_reconnect: ReconnectCallback | None = None,
|
|
86
91
|
on_disconnect: DisconnectCallback | None = None,
|
|
92
|
+
on_heartbeat_ack: HeartbeatAckCallback | None = None,
|
|
87
93
|
additional_headers: dict[str, str] | None = None,
|
|
88
94
|
):
|
|
89
95
|
self.logger = logger
|
|
@@ -140,6 +146,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
140
146
|
self._connected_event = asyncio.Event()
|
|
141
147
|
self._conn_generation = 0
|
|
142
148
|
self._supervisor_task: asyncio.Task[None] | None = None
|
|
149
|
+
self._shutdown_task: asyncio.Task[None] | None = None
|
|
143
150
|
self._message_routing_task: asyncio.Task[None] | None = None
|
|
144
151
|
self._initial_connection_future: asyncio.Future[None] | None = None
|
|
145
152
|
self._rapid_disconnects: deque[float] = deque()
|
|
@@ -149,6 +156,21 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
149
156
|
self._pending_heartbeat_ref: str | None = None
|
|
150
157
|
self._on_reconnect = on_reconnect
|
|
151
158
|
self._on_disconnect = on_disconnect
|
|
159
|
+
self._on_heartbeat_ack = on_heartbeat_ack
|
|
160
|
+
self._forced_close_pending = False
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def _active_shutdown(self) -> asyncio.Task[None] | None:
|
|
164
|
+
task = self._shutdown_task
|
|
165
|
+
return task if task is not None and not task.done() else None
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def _is_fully_closed(self) -> bool:
|
|
169
|
+
return (
|
|
170
|
+
self._state == ClientState.CLOSED
|
|
171
|
+
and not self._topic_subscriptions
|
|
172
|
+
and self.connection is None
|
|
173
|
+
)
|
|
152
174
|
|
|
153
175
|
@staticmethod
|
|
154
176
|
def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
|
|
@@ -156,7 +178,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
156
178
|
|
|
157
179
|
async def __aenter__(self) -> PHXChannelsClient:
|
|
158
180
|
self.logger.debug("Entering PHXChannelsClient context")
|
|
159
|
-
if self._state != ClientState.CLOSED:
|
|
181
|
+
if self._state != ClientState.CLOSED or self._active_shutdown is not None:
|
|
160
182
|
raise PHXConnectionError("Client is already running")
|
|
161
183
|
|
|
162
184
|
self._shutdown_event.clear()
|
|
@@ -185,51 +207,52 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
185
207
|
self.logger.debug("Leaving PHXChannelsClient context")
|
|
186
208
|
await self.shutdown("Leaving PHXChannelsClient context")
|
|
187
209
|
|
|
188
|
-
async def shutdown(
|
|
189
|
-
self
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
self.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
|
|
210
|
+
async def shutdown(self, reason: str) -> None:
|
|
211
|
+
task = self._active_shutdown
|
|
212
|
+
if task is None:
|
|
213
|
+
if self._is_fully_closed:
|
|
214
|
+
return
|
|
215
|
+
task = self._shutdown_task = asyncio.create_task(self._shutdown(reason))
|
|
216
|
+
# Every caller waits for the same shutdown, and one caller's
|
|
217
|
+
# cancellation never aborts it.
|
|
218
|
+
await asyncio.shield(task)
|
|
219
|
+
|
|
220
|
+
async def _shutdown(self, reason: str) -> None:
|
|
199
221
|
self.logger.info("Event loop shutting down! reason=%s", reason)
|
|
200
222
|
|
|
201
223
|
if self._state not in (ClientState.SHUTTING_DOWN, ClientState.CLOSED):
|
|
202
224
|
self._transition_state(ClientState.SHUTTING_DOWN)
|
|
203
|
-
|
|
204
225
|
self._shutdown_event.set()
|
|
205
226
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
unsubscribe_tasks = [
|
|
209
|
-
self.unsubscribe_from_topic(topic, _allow_disconnected=True)
|
|
210
|
-
for topic in topics_to_unsubscribe
|
|
211
|
-
]
|
|
212
|
-
results = await asyncio.gather(*unsubscribe_tasks, return_exceptions=True)
|
|
213
|
-
|
|
214
|
-
for topic, result in zip(topics_to_unsubscribe, results, strict=False):
|
|
215
|
-
if isinstance(result, Exception):
|
|
216
|
-
self.logger.warning(
|
|
217
|
-
"Failed to unsubscribe from topic %s during shutdown: %s",
|
|
218
|
-
topic,
|
|
219
|
-
result,
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
if self._supervisor_task and not self._supervisor_task.done():
|
|
223
|
-
self._supervisor_task.cancel()
|
|
224
|
-
try:
|
|
225
|
-
await self._supervisor_task
|
|
226
|
-
except asyncio.CancelledError:
|
|
227
|
-
self.logger.debug("Supervisor task cancelled during shutdown")
|
|
228
|
-
|
|
227
|
+
await self._unsubscribe_all()
|
|
228
|
+
await self._stop_supervisor()
|
|
229
229
|
await self._cleanup_connection()
|
|
230
230
|
self._connected_event.clear()
|
|
231
231
|
self._transition_state(ClientState.CLOSED)
|
|
232
232
|
|
|
233
|
+
async def _unsubscribe_all(self) -> None:
|
|
234
|
+
topics = list(self._topic_subscriptions)
|
|
235
|
+
results = await asyncio.gather(
|
|
236
|
+
*(
|
|
237
|
+
self.unsubscribe_from_topic(topic, _allow_disconnected=True)
|
|
238
|
+
for topic in topics
|
|
239
|
+
),
|
|
240
|
+
return_exceptions=True,
|
|
241
|
+
)
|
|
242
|
+
for topic, result in zip(topics, results, strict=True):
|
|
243
|
+
if isinstance(result, Exception):
|
|
244
|
+
self.logger.warning(
|
|
245
|
+
"Failed to unsubscribe from topic %s during shutdown: %s",
|
|
246
|
+
topic,
|
|
247
|
+
result,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def _stop_supervisor(self) -> None:
|
|
251
|
+
# Cleared, so run_forever() after the stop reports the client isn't running.
|
|
252
|
+
supervisor, self._supervisor_task = self._supervisor_task, None
|
|
253
|
+
if supervisor is not None and not supervisor.done():
|
|
254
|
+
await cancel_and_wait(supervisor)
|
|
255
|
+
|
|
233
256
|
def _transition_state(self, new_state: ClientState) -> None:
|
|
234
257
|
if self._state == new_state:
|
|
235
258
|
return
|
|
@@ -8,7 +8,11 @@ from enum import Enum
|
|
|
8
8
|
|
|
9
9
|
from websockets import ClientConnection
|
|
10
10
|
|
|
11
|
-
from phoenix_channels_python_client.phx_messages import
|
|
11
|
+
from phoenix_channels_python_client.phx_messages import (
|
|
12
|
+
PHOENIX_TOPIC,
|
|
13
|
+
ChannelMessage,
|
|
14
|
+
Event,
|
|
15
|
+
)
|
|
12
16
|
from phoenix_channels_python_client.topic_subscription import TopicSubscription
|
|
13
17
|
from phoenix_channels_python_client.utils import make_message
|
|
14
18
|
|
|
@@ -155,7 +159,7 @@ class PHXProtocolHandler:
|
|
|
155
159
|
self.logger.debug("Processing message - %s", phx_message)
|
|
156
160
|
topic = phx_message.topic
|
|
157
161
|
|
|
158
|
-
if topic ==
|
|
162
|
+
if topic == PHOENIX_TOPIC:
|
|
159
163
|
if on_heartbeat_response is not None:
|
|
160
164
|
on_heartbeat_response(phx_message)
|
|
161
165
|
continue
|