phoenix-channels-python-client 0.2.5__tar.gz → 0.3.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.2.5/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.3.1}/PKG-INFO +52 -16
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/README.md +49 -15
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/__init__.py +5 -4
- phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/authentication.py +43 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client.py +83 -61
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client_state_machine.py +1 -3
- phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/client_types.py +73 -0
- phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/diagnostics.py +131 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/exceptions.py +0 -2
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/phx_messages.py +16 -9
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/protocol_handler.py +113 -69
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/reconnect_controller.py +73 -39
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/shutdown_signals.py +1 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/supervisor.py +219 -182
- phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/topic_runtime.py +783 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/topic_subscription.py +21 -6
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/utils.py +14 -15
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1/phoenix_channels_python_client.egg-info}/PKG-INFO +52 -16
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +14 -2
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
- phoenix_channels_python_client-0.3.1/pyproject.toml +87 -0
- phoenix_channels_python_client-0.3.1/tests/test_cancel_and_wait.py +53 -0
- phoenix_channels_python_client-0.3.1/tests/test_channel_recovery.py +350 -0
- phoenix_channels_python_client-0.3.1/tests/test_client_state.py +77 -0
- phoenix_channels_python_client-0.3.1/tests/test_connection.py +249 -0
- phoenix_channels_python_client-0.3.1/tests/test_heartbeat.py +77 -0
- phoenix_channels_python_client-0.3.1/tests/test_protocol_handler.py +228 -0
- phoenix_channels_python_client-0.3.1/tests/test_reconnect.py +627 -0
- phoenix_channels_python_client-0.3.1/tests/test_reconnect_policy_invariants.py +177 -0
- phoenix_channels_python_client-0.3.1/tests/test_reconnect_stress.py +185 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/tests/test_run_forever_signals.py +6 -9
- phoenix_channels_python_client-0.3.1/tests/test_security.py +316 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/tests/test_shutdown_races.py +128 -28
- phoenix_channels_python_client-0.3.1/tests/test_subscriptions.py +445 -0
- phoenix_channels_python_client-0.3.1/tests/test_supervisor_internals.py +178 -0
- phoenix_channels_python_client-0.3.1/tests/test_topic_runtime_internals.py +303 -0
- phoenix_channels_python_client-0.2.5/phoenix_channels_python_client/client_types.py +0 -102
- phoenix_channels_python_client-0.2.5/phoenix_channels_python_client/topic_runtime.py +0 -574
- phoenix_channels_python_client-0.2.5/pyproject.toml +0 -52
- phoenix_channels_python_client-0.2.5/tests/test_internal_components.py +0 -1117
- phoenix_channels_python_client-0.2.5/tests/test_reconnect_policy_invariants.py +0 -124
- phoenix_channels_python_client-0.2.5/tests/test_reconnect_stress.py +0 -136
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/LICENSE +0 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
- {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -14,10 +14,12 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
14
14
|
Requires-Python: >=3.11
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pydantic>=2.8
|
|
17
18
|
Requires-Dist: websockets>=14.2
|
|
18
19
|
Provides-Extra: dev
|
|
19
20
|
Requires-Dist: pytest; extra == "dev"
|
|
20
21
|
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
21
23
|
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
22
24
|
Requires-Dist: commitizen>=3.13.0; extra == "dev"
|
|
23
25
|
Requires-Dist: pyrefly; extra == "dev"
|
|
@@ -60,21 +62,19 @@ Set up development environment:
|
|
|
60
62
|
```bash
|
|
61
63
|
git clone https://github.com/band-ai/phoenix-channels-python-client.git
|
|
62
64
|
cd phoenix-channels-python-client
|
|
63
|
-
|
|
64
|
-
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
65
|
-
python3 -m pip install -U pip
|
|
66
|
-
pip install -e ".[dev]"
|
|
65
|
+
uv sync --extra dev
|
|
67
66
|
```
|
|
68
67
|
|
|
69
68
|
Run tests:
|
|
70
69
|
|
|
71
70
|
```bash
|
|
72
|
-
pytest
|
|
71
|
+
uv run pytest
|
|
73
72
|
```
|
|
74
73
|
|
|
75
74
|
### Dependencies
|
|
76
75
|
|
|
77
76
|
- Python 3.11+
|
|
77
|
+
- `pydantic>=2.8`
|
|
78
78
|
- `websockets>=14.2`
|
|
79
79
|
|
|
80
80
|
## Quick Start
|
|
@@ -144,11 +144,11 @@ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
|
|
|
144
144
|
`phx_error`. Don't use these names for your own events.
|
|
145
145
|
|
|
146
146
|
- The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
|
|
147
|
-
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
- The client handles `phx_error` and `phx_close` itself, and neither reaches
|
|
148
|
+
your handlers: a crashed channel is rejoined, and a channel the server
|
|
149
|
+
closes loses its topic (see [Channel recovery](#channel-recovery)).
|
|
150
|
+
- `add_event_handler` raises `ValueError` for `PHXEvent.error` and
|
|
151
|
+
`PHXEvent.close`. Use `on_topic_lost` and `is_topic_joined()` instead.
|
|
152
152
|
|
|
153
153
|
## Protocol Versions
|
|
154
154
|
|
|
@@ -299,10 +299,11 @@ Everything after `api_key` is keyword-only:
|
|
|
299
299
|
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
300
300
|
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
301
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
|
|
302
|
+
| `callback_drain_timeout_s` | `2.0` | How long the message being handled (its message handler, then its event handler) may finish before a rejoin cancels it; queued messages are dropped |
|
|
303
303
|
| `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
|
|
304
304
|
| `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
|
|
305
305
|
| `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
|
|
306
|
+
| `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
|
|
306
307
|
| `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
|
|
307
308
|
|
|
308
309
|
Exceptions raised in callbacks are logged, not raised.
|
|
@@ -311,8 +312,8 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
311
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`.
|
|
313
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`.
|
|
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.
|
|
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
|
+
- `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.
|
|
316
317
|
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
317
318
|
|
|
318
319
|
### Reconnects
|
|
@@ -327,6 +328,21 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
327
328
|
|
|
328
329
|
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
|
|
|
331
|
+
A `ReconnectPolicy` is validated when it is built: an out-of-range or unknown field raises `pydantic.ValidationError`, which is a `ValueError`. To derive one policy from another, validate the merged fields:
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
base = ReconnectPolicy()
|
|
335
|
+
policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Channel recovery
|
|
339
|
+
|
|
340
|
+
A channel can fail while the socket stays up. When its process crashes, the server sends `phx_error`, and the client rejoins that topic on the same socket, waiting the `ReconnectPolicy` backoff (`base_delay_s`, `factor`, `max_delay_s`, with equal jitter) before each attempt. A rejoin that times out is retried the same way, including one after a socket reconnect. A drop of the socket hands the topic to the socket's own rejoin.
|
|
341
|
+
|
|
342
|
+
The client drops the topic and calls `on_topic_lost(topic, error)` once when the server rejects a rejoin, after a crash or a reconnect, closes the channel with `phx_close`, or its message processor fails. It never calls it for `unsubscribe_from_topic()`, `shutdown()` or a failed first `subscribe_to_topic()`, which raises instead. Subscribing to the topic again from the callback works. `shutdown()` cancels the callback, including when the callback itself called `shutdown()`, so code after that call does not run.
|
|
343
|
+
|
|
344
|
+
`get_current_subscriptions()` lists the subscribed topics. `is_topic_joined(topic)` tells whether a topic's channel is joined right now: it's false while the socket is down or a crashed channel waits to rejoin. Unsubscribing a topic whose channel is being recovered completes without waiting for a leave reply.
|
|
345
|
+
|
|
330
346
|
### Heartbeats
|
|
331
347
|
|
|
332
348
|
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()`.
|
|
@@ -349,7 +365,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
349
365
|
|
|
350
366
|
## Security Considerations
|
|
351
367
|
|
|
352
|
-
**API Key in URL**:
|
|
368
|
+
**API Key in URL**: Without an authentication header, 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:
|
|
353
369
|
|
|
354
370
|
- Server access logs
|
|
355
371
|
- Reverse proxies and load balancers
|
|
@@ -360,8 +376,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
360
376
|
- Use short-lived, rotatable tokens rather than long-lived API keys
|
|
361
377
|
- Consider this limitation when evaluating this client for sensitive environments
|
|
362
378
|
|
|
363
|
-
|
|
379
|
+
Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
|
|
364
380
|
|
|
365
381
|
---
|
|
366
382
|
|
|
367
383
|
**Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.
|
|
384
|
+
|
|
385
|
+
### Authentication and diagnostic safety
|
|
386
|
+
|
|
387
|
+
A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
|
|
388
|
+
every connection and reconnect. Embedded API-key parameters are removed; empty
|
|
389
|
+
or conflicting authentication headers and simultaneous token-query authentication
|
|
390
|
+
are rejected before connecting. A JWT header overrides an embedded token and takes
|
|
391
|
+
precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
|
|
392
|
+
is preserved and remains visible to servers and proxies. No fallback is attempted.
|
|
393
|
+
|
|
394
|
+
`client.logger` forwards sanitized connection records without raw arguments,
|
|
395
|
+
exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
|
|
396
|
+
the credential policy to transport errors a caller needs to retain. Caller-visible
|
|
397
|
+
errors may still contain personal data and should not be logged outside this policy.
|
|
398
|
+
|
|
399
|
+
Connection diagnostics omit dynamic strings and transport payloads, including URLs,
|
|
400
|
+
headers, frame bodies, user/topic identities, and untrusted server error details.
|
|
401
|
+
Logs retain static lifecycle descriptions, native connection states,
|
|
402
|
+
and exception types. Application data and caller-visible errors are not changed;
|
|
403
|
+
applications remain responsible for logs outside this connection-owned boundary.
|
|
@@ -31,21 +31,19 @@ Set up development environment:
|
|
|
31
31
|
```bash
|
|
32
32
|
git clone https://github.com/band-ai/phoenix-channels-python-client.git
|
|
33
33
|
cd phoenix-channels-python-client
|
|
34
|
-
|
|
35
|
-
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
36
|
-
python3 -m pip install -U pip
|
|
37
|
-
pip install -e ".[dev]"
|
|
34
|
+
uv sync --extra dev
|
|
38
35
|
```
|
|
39
36
|
|
|
40
37
|
Run tests:
|
|
41
38
|
|
|
42
39
|
```bash
|
|
43
|
-
pytest
|
|
40
|
+
uv run pytest
|
|
44
41
|
```
|
|
45
42
|
|
|
46
43
|
### Dependencies
|
|
47
44
|
|
|
48
45
|
- Python 3.11+
|
|
46
|
+
- `pydantic>=2.8`
|
|
49
47
|
- `websockets>=14.2`
|
|
50
48
|
|
|
51
49
|
## Quick Start
|
|
@@ -115,11 +113,11 @@ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
|
|
|
115
113
|
`phx_error`. Don't use these names for your own events.
|
|
116
114
|
|
|
117
115
|
- The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
|
|
118
|
-
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
116
|
+
- The client handles `phx_error` and `phx_close` itself, and neither reaches
|
|
117
|
+
your handlers: a crashed channel is rejoined, and a channel the server
|
|
118
|
+
closes loses its topic (see [Channel recovery](#channel-recovery)).
|
|
119
|
+
- `add_event_handler` raises `ValueError` for `PHXEvent.error` and
|
|
120
|
+
`PHXEvent.close`. Use `on_topic_lost` and `is_topic_joined()` instead.
|
|
123
121
|
|
|
124
122
|
## Protocol Versions
|
|
125
123
|
|
|
@@ -270,10 +268,11 @@ Everything after `api_key` is keyword-only:
|
|
|
270
268
|
| `join_timeout_s` | `10.0` | Wait for a join reply |
|
|
271
269
|
| `leave_timeout_s` | `5.0` | Wait for a leave reply |
|
|
272
270
|
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
273
|
-
| `callback_drain_timeout_s` | `2.0` | How long
|
|
271
|
+
| `callback_drain_timeout_s` | `2.0` | How long the message being handled (its message handler, then its event handler) may finish before a rejoin cancels it; queued messages are dropped |
|
|
274
272
|
| `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
|
|
275
273
|
| `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
|
|
276
274
|
| `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
|
|
275
|
+
| `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client drops a topic you didn't unsubscribe |
|
|
277
276
|
| `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
|
|
278
277
|
|
|
279
278
|
Exceptions raised in callbacks are logged, not raised.
|
|
@@ -282,8 +281,8 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
282
281
|
|
|
283
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`.
|
|
284
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`.
|
|
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.
|
|
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
|
+
- `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.
|
|
287
286
|
- Exceptions live in `phoenix_channels_python_client.exceptions`.
|
|
288
287
|
|
|
289
288
|
### Reconnects
|
|
@@ -298,6 +297,21 @@ Exceptions raised in callbacks are logged, not raised.
|
|
|
298
297
|
|
|
299
298
|
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
299
|
|
|
300
|
+
A `ReconnectPolicy` is validated when it is built: an out-of-range or unknown field raises `pydantic.ValidationError`, which is a `ValueError`. To derive one policy from another, validate the merged fields:
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
base = ReconnectPolicy()
|
|
304
|
+
policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Channel recovery
|
|
308
|
+
|
|
309
|
+
A channel can fail while the socket stays up. When its process crashes, the server sends `phx_error`, and the client rejoins that topic on the same socket, waiting the `ReconnectPolicy` backoff (`base_delay_s`, `factor`, `max_delay_s`, with equal jitter) before each attempt. A rejoin that times out is retried the same way, including one after a socket reconnect. A drop of the socket hands the topic to the socket's own rejoin.
|
|
310
|
+
|
|
311
|
+
The client drops the topic and calls `on_topic_lost(topic, error)` once when the server rejects a rejoin, after a crash or a reconnect, closes the channel with `phx_close`, or its message processor fails. It never calls it for `unsubscribe_from_topic()`, `shutdown()` or a failed first `subscribe_to_topic()`, which raises instead. Subscribing to the topic again from the callback works. `shutdown()` cancels the callback, including when the callback itself called `shutdown()`, so code after that call does not run.
|
|
312
|
+
|
|
313
|
+
`get_current_subscriptions()` lists the subscribed topics. `is_topic_joined(topic)` tells whether a topic's channel is joined right now: it's false while the socket is down or a crashed channel waits to rejoin. Unsubscribing a topic whose channel is being recovered completes without waiting for a leave reply.
|
|
314
|
+
|
|
301
315
|
### Heartbeats
|
|
302
316
|
|
|
303
317
|
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()`.
|
|
@@ -320,7 +334,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
320
334
|
|
|
321
335
|
## Security Considerations
|
|
322
336
|
|
|
323
|
-
**API Key in URL**:
|
|
337
|
+
**API Key in URL**: Without an authentication header, 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:
|
|
324
338
|
|
|
325
339
|
- Server access logs
|
|
326
340
|
- Reverse proxies and load balancers
|
|
@@ -331,8 +345,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
331
345
|
- Use short-lived, rotatable tokens rather than long-lived API keys
|
|
332
346
|
- Consider this limitation when evaluating this client for sensitive environments
|
|
333
347
|
|
|
334
|
-
|
|
348
|
+
Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
|
|
335
349
|
|
|
336
350
|
---
|
|
337
351
|
|
|
338
352
|
**Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.
|
|
353
|
+
|
|
354
|
+
### Authentication and diagnostic safety
|
|
355
|
+
|
|
356
|
+
A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
|
|
357
|
+
every connection and reconnect. Embedded API-key parameters are removed; empty
|
|
358
|
+
or conflicting authentication headers and simultaneous token-query authentication
|
|
359
|
+
are rejected before connecting. A JWT header overrides an embedded token and takes
|
|
360
|
+
precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
|
|
361
|
+
is preserved and remains visible to servers and proxies. No fallback is attempted.
|
|
362
|
+
|
|
363
|
+
`client.logger` forwards sanitized connection records without raw arguments,
|
|
364
|
+
exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
|
|
365
|
+
the credential policy to transport errors a caller needs to retain. Caller-visible
|
|
366
|
+
errors may still contain personal data and should not be logged outside this policy.
|
|
367
|
+
|
|
368
|
+
Connection diagnostics omit dynamic strings and transport payloads, including URLs,
|
|
369
|
+
headers, frame bodies, user/topic identities, and untrusted server error details.
|
|
370
|
+
Logs retain static lifecycle descriptions, native connection states,
|
|
371
|
+
and exception types. Application data and caller-visible errors are not changed;
|
|
372
|
+
applications remain responsible for logs outside this connection-owned boundary.
|
|
@@ -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.1"
|
|
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
|
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
API_KEY_PARAMETER = "api_key"
|
|
6
|
+
TOKEN_PARAMETER = "token" # noqa: S105 -- protocol parameter name
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthenticationHeader(StrEnum):
|
|
10
|
+
API_KEY = "x-api-key"
|
|
11
|
+
TOKEN = "x-auth-token" # noqa: S105 -- protocol header name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_headers(headers: dict[str, str] | None) -> dict[str, str]:
|
|
15
|
+
normalized = dict(headers or {})
|
|
16
|
+
for header in AuthenticationHeader:
|
|
17
|
+
credentials = [
|
|
18
|
+
value for name, value in normalized.items() if name.casefold() == header
|
|
19
|
+
]
|
|
20
|
+
if not credentials:
|
|
21
|
+
continue
|
|
22
|
+
if (
|
|
23
|
+
any(not value.strip() for value in credentials)
|
|
24
|
+
or len(set(credentials)) != 1
|
|
25
|
+
):
|
|
26
|
+
raise ValueError(f"{header} headers must contain one nonempty credential")
|
|
27
|
+
normalized = {
|
|
28
|
+
name: value
|
|
29
|
+
for name, value in normalized.items()
|
|
30
|
+
if name.casefold() != header
|
|
31
|
+
}
|
|
32
|
+
normalized[header] = credentials[0]
|
|
33
|
+
return normalized
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def select_authentication_header(
|
|
37
|
+
headers: dict[str, str],
|
|
38
|
+
) -> AuthenticationHeader | None:
|
|
39
|
+
# The platform gives a supplied JWT precedence over an API key.
|
|
40
|
+
for header in (AuthenticationHeader.TOKEN, AuthenticationHeader.API_KEY):
|
|
41
|
+
if header in headers:
|
|
42
|
+
return header
|
|
43
|
+
return None
|
|
@@ -5,21 +5,29 @@ 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, cast
|
|
8
9
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
9
10
|
|
|
10
11
|
from websockets import ClientConnection
|
|
11
12
|
|
|
13
|
+
from phoenix_channels_python_client.authentication import (
|
|
14
|
+
API_KEY_PARAMETER,
|
|
15
|
+
TOKEN_PARAMETER,
|
|
16
|
+
AuthenticationHeader,
|
|
17
|
+
normalize_headers,
|
|
18
|
+
select_authentication_header,
|
|
19
|
+
)
|
|
12
20
|
from phoenix_channels_python_client.client_state_machine import transition_client_state
|
|
13
|
-
from phoenix_channels_python_client.client_types import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
validate_reconnect_policy,
|
|
21
|
+
from phoenix_channels_python_client.client_types import ClientState, ReconnectPolicy
|
|
22
|
+
from phoenix_channels_python_client.diagnostics import (
|
|
23
|
+
ConnectionLogger,
|
|
24
|
+
diagnostic_secrets,
|
|
18
25
|
)
|
|
19
26
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
20
27
|
from phoenix_channels_python_client.protocol_handler import (
|
|
21
|
-
|
|
28
|
+
DEFAULT_PROTOCOL_VERSION,
|
|
22
29
|
PhoenixChannelsProtocolVersion,
|
|
30
|
+
PHXProtocolHandler,
|
|
23
31
|
)
|
|
24
32
|
from phoenix_channels_python_client.reconnect_controller import ReconnectControllerMixin
|
|
25
33
|
from phoenix_channels_python_client.supervisor import SupervisorMixin
|
|
@@ -35,51 +43,70 @@ DisconnectCallback = Callable[[Exception | None], Awaitable[None]]
|
|
|
35
43
|
# on the message-routing hot path, so blocking work here stalls heartbeat
|
|
36
44
|
# sends and message routing for every connection.
|
|
37
45
|
HeartbeatAckCallback = Callable[[], None]
|
|
46
|
+
TopicLostCallback = Callable[[str, Exception], Awaitable[None]]
|
|
38
47
|
|
|
39
48
|
|
|
40
49
|
def _build_channel_socket_urls(
|
|
41
|
-
websocket_url: str,
|
|
50
|
+
websocket_url: str,
|
|
51
|
+
api_key: str,
|
|
52
|
+
vsn: str,
|
|
53
|
+
*,
|
|
54
|
+
header_auth: AuthenticationHeader | None = None,
|
|
42
55
|
) -> tuple[str, str]:
|
|
43
56
|
split_url = urlsplit(websocket_url)
|
|
44
57
|
query_params = parse_qsl(split_url.query, keep_blank_values=True)
|
|
58
|
+
if header_auth is AuthenticationHeader.API_KEY and any(
|
|
59
|
+
key.casefold() == TOKEN_PARAMETER and value for key, value in query_params
|
|
60
|
+
):
|
|
61
|
+
raise ValueError("token query authentication cannot be combined with x-api-key")
|
|
45
62
|
filtered = [
|
|
46
|
-
(key, value)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
split_url.netloc,
|
|
54
|
-
split_url.path,
|
|
55
|
-
urlencode(with_auth),
|
|
56
|
-
split_url.fragment,
|
|
63
|
+
(key, value)
|
|
64
|
+
for key, value in query_params
|
|
65
|
+
if key.casefold()
|
|
66
|
+
not in (
|
|
67
|
+
{API_KEY_PARAMETER, "vsn", TOKEN_PARAMETER}
|
|
68
|
+
if header_auth
|
|
69
|
+
else {API_KEY_PARAMETER, "vsn"}
|
|
57
70
|
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
]
|
|
72
|
+
with_auth = [
|
|
73
|
+
*filtered,
|
|
74
|
+
*([] if header_auth else [(API_KEY_PARAMETER, api_key)]),
|
|
75
|
+
("vsn", vsn),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
def build(params: list[tuple[str, str]]) -> str:
|
|
79
|
+
return urlunsplit(
|
|
80
|
+
(
|
|
81
|
+
split_url.scheme,
|
|
82
|
+
split_url.netloc,
|
|
83
|
+
split_url.path,
|
|
84
|
+
urlencode(params),
|
|
85
|
+
split_url.fragment,
|
|
86
|
+
)
|
|
71
87
|
)
|
|
88
|
+
|
|
89
|
+
return build(with_auth), build(
|
|
90
|
+
[
|
|
91
|
+
(
|
|
92
|
+
key,
|
|
93
|
+
"***"
|
|
94
|
+
if key.casefold() in {API_KEY_PARAMETER, TOKEN_PARAMETER}
|
|
95
|
+
else value,
|
|
96
|
+
)
|
|
97
|
+
for key, value in with_auth
|
|
98
|
+
]
|
|
72
99
|
)
|
|
73
|
-
return connect_url, redacted_url
|
|
74
100
|
|
|
75
101
|
|
|
76
102
|
class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
|
|
77
|
-
def __init__(
|
|
103
|
+
def __init__( # noqa: PLR0913 # the public options
|
|
78
104
|
self,
|
|
79
105
|
websocket_url: str,
|
|
80
106
|
api_key: str,
|
|
81
107
|
*,
|
|
82
|
-
protocol_version: PhoenixChannelsProtocolVersion
|
|
108
|
+
protocol_version: PhoenixChannelsProtocolVersion
|
|
109
|
+
| str = DEFAULT_PROTOCOL_VERSION,
|
|
83
110
|
auto_reconnect: bool = True,
|
|
84
111
|
reconnect_policy: ReconnectPolicy | None = None,
|
|
85
112
|
join_timeout_s: float = 10.0,
|
|
@@ -90,9 +117,13 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
90
117
|
on_reconnect: ReconnectCallback | None = None,
|
|
91
118
|
on_disconnect: DisconnectCallback | None = None,
|
|
92
119
|
on_heartbeat_ack: HeartbeatAckCallback | None = None,
|
|
120
|
+
on_topic_lost: TopicLostCallback | None = None,
|
|
93
121
|
additional_headers: dict[str, str] | None = None,
|
|
94
|
-
):
|
|
95
|
-
self.
|
|
122
|
+
) -> None:
|
|
123
|
+
self.additional_headers = normalize_headers(additional_headers)
|
|
124
|
+
self.logger = ConnectionLogger(
|
|
125
|
+
logger, diagnostic_secrets(websocket_url, api_key, self.additional_headers)
|
|
126
|
+
)
|
|
96
127
|
|
|
97
128
|
if heartbeat_interval_s is not None and heartbeat_interval_s <= 0:
|
|
98
129
|
raise ValueError("heartbeat_interval_s must be > 0 or None to disable")
|
|
@@ -105,30 +136,19 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
105
136
|
raise ValueError("max_topic_queue_size must be > 0")
|
|
106
137
|
if callback_drain_timeout_s <= 0:
|
|
107
138
|
raise ValueError("callback_drain_timeout_s must be > 0")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
raise ValueError("Invalid reconnect policy configuration") from exc
|
|
112
|
-
|
|
113
|
-
vsn = (
|
|
114
|
-
"2.0.0"
|
|
115
|
-
if protocol_version == PhoenixChannelsProtocolVersion.V2
|
|
116
|
-
else "1.0.0"
|
|
139
|
+
|
|
140
|
+
self._protocol_handler = PHXProtocolHandler(
|
|
141
|
+
protocol_version, diagnostic_logger=self.logger
|
|
117
142
|
)
|
|
118
143
|
connect_url, redacted_url = _build_channel_socket_urls(
|
|
119
144
|
websocket_url=websocket_url,
|
|
120
145
|
api_key=api_key,
|
|
121
|
-
vsn=vsn,
|
|
146
|
+
vsn=self._protocol_handler.protocol_version.vsn,
|
|
147
|
+
header_auth=select_authentication_header(self.additional_headers),
|
|
122
148
|
)
|
|
123
149
|
self.channel_socket_url = connect_url
|
|
124
150
|
self.channel_socket_url_redacted = redacted_url
|
|
125
151
|
|
|
126
|
-
# Extra WebSocket handshake headers, re-sent on every (re)connect. Lets a
|
|
127
|
-
# caller send the API key as an ``x-api-key`` header instead of (or
|
|
128
|
-
# alongside) the URL query, so a trusted proxy can inject/replace it
|
|
129
|
-
# in-header and the credential stays out of URL/proxy logs.
|
|
130
|
-
self.additional_headers = dict(additional_headers or {})
|
|
131
|
-
|
|
132
152
|
self.auto_reconnect = auto_reconnect
|
|
133
153
|
self.reconnect_policy = reconnect_policy or ReconnectPolicy()
|
|
134
154
|
self.join_timeout_s = join_timeout_s
|
|
@@ -138,7 +158,6 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
138
158
|
|
|
139
159
|
self.connection: ClientConnection | None = None
|
|
140
160
|
self._topic_subscriptions: dict[str, TopicSubscription] = {}
|
|
141
|
-
self._protocol_handler = PHXProtocolHandler(protocol_version)
|
|
142
161
|
self._ref_counter = 0
|
|
143
162
|
self._state = ClientState.CLOSED
|
|
144
163
|
self._topics_lock = asyncio.Lock()
|
|
@@ -157,8 +176,14 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
157
176
|
self._on_reconnect = on_reconnect
|
|
158
177
|
self._on_disconnect = on_disconnect
|
|
159
178
|
self._on_heartbeat_ack = on_heartbeat_ack
|
|
179
|
+
self._on_topic_lost = on_topic_lost
|
|
180
|
+
self._lifecycle_tasks: set[asyncio.Task[None]] = set()
|
|
160
181
|
self._forced_close_pending = False
|
|
161
182
|
|
|
183
|
+
def redact_diagnostic(self, text: str) -> str:
|
|
184
|
+
"""Sanitize untrusted transport error text using this connection's policy."""
|
|
185
|
+
return cast(ConnectionLogger, self.logger).redact(text)
|
|
186
|
+
|
|
162
187
|
@property
|
|
163
188
|
def _active_shutdown(self) -> asyncio.Task[None] | None:
|
|
164
189
|
task = self._shutdown_task
|
|
@@ -172,11 +197,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
172
197
|
and self.connection is None
|
|
173
198
|
)
|
|
174
199
|
|
|
175
|
-
|
|
176
|
-
def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
|
|
177
|
-
return reconnect_policy_is_invalid(policy)
|
|
178
|
-
|
|
179
|
-
async def __aenter__(self) -> PHXChannelsClient:
|
|
200
|
+
async def __aenter__(self) -> Self:
|
|
180
201
|
self.logger.debug("Entering PHXChannelsClient context")
|
|
181
202
|
if self._state != ClientState.CLOSED or self._active_shutdown is not None:
|
|
182
203
|
raise PHXConnectionError("Client is already running")
|
|
@@ -185,6 +206,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
185
206
|
self._connected_event.clear()
|
|
186
207
|
self._rapid_disconnects.clear()
|
|
187
208
|
self._terminal_error = None
|
|
209
|
+
self._conn_generation = 0
|
|
188
210
|
self._initial_connection_future = asyncio.get_running_loop().create_future()
|
|
189
211
|
self._transition_state(ClientState.CONNECTING)
|
|
190
212
|
|
|
@@ -225,6 +247,8 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
225
247
|
self._shutdown_event.set()
|
|
226
248
|
|
|
227
249
|
await self._unsubscribe_all()
|
|
250
|
+
# Includes on_topic_lost callbacks still running for topics already gone.
|
|
251
|
+
await cancel_and_wait(*self._lifecycle_tasks)
|
|
228
252
|
await self._stop_supervisor()
|
|
229
253
|
await self._cleanup_connection()
|
|
230
254
|
self._connected_event.clear()
|
|
@@ -258,7 +282,5 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
258
282
|
return
|
|
259
283
|
|
|
260
284
|
transitioned_state = transition_client_state(self._state, new_state)
|
|
261
|
-
self.logger.debug(
|
|
262
|
-
"Client state transition: %s -> %s", self._state.value, new_state.value
|
|
263
|
-
)
|
|
285
|
+
self.logger.debug("Client state transition: %s -> %s", self._state, new_state)
|
|
264
286
|
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
|