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.
Files changed (47) hide show
  1. {phoenix_channels_python_client-0.2.5/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.3.1}/PKG-INFO +52 -16
  2. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/README.md +49 -15
  3. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/__init__.py +5 -4
  4. phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/authentication.py +43 -0
  5. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client.py +83 -61
  6. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client_state_machine.py +1 -3
  7. phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/client_types.py +73 -0
  8. phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/diagnostics.py +131 -0
  9. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/exceptions.py +0 -2
  10. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/phx_messages.py +16 -9
  11. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/protocol_handler.py +113 -69
  12. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/reconnect_controller.py +73 -39
  13. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/shutdown_signals.py +1 -0
  14. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/supervisor.py +219 -182
  15. phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/topic_runtime.py +783 -0
  16. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/topic_subscription.py +21 -6
  17. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/utils.py +14 -15
  18. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1/phoenix_channels_python_client.egg-info}/PKG-INFO +52 -16
  19. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +14 -2
  20. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/requires.txt +2 -0
  21. phoenix_channels_python_client-0.3.1/pyproject.toml +87 -0
  22. phoenix_channels_python_client-0.3.1/tests/test_cancel_and_wait.py +53 -0
  23. phoenix_channels_python_client-0.3.1/tests/test_channel_recovery.py +350 -0
  24. phoenix_channels_python_client-0.3.1/tests/test_client_state.py +77 -0
  25. phoenix_channels_python_client-0.3.1/tests/test_connection.py +249 -0
  26. phoenix_channels_python_client-0.3.1/tests/test_heartbeat.py +77 -0
  27. phoenix_channels_python_client-0.3.1/tests/test_protocol_handler.py +228 -0
  28. phoenix_channels_python_client-0.3.1/tests/test_reconnect.py +627 -0
  29. phoenix_channels_python_client-0.3.1/tests/test_reconnect_policy_invariants.py +177 -0
  30. phoenix_channels_python_client-0.3.1/tests/test_reconnect_stress.py +185 -0
  31. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/tests/test_run_forever_signals.py +6 -9
  32. phoenix_channels_python_client-0.3.1/tests/test_security.py +316 -0
  33. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/tests/test_shutdown_races.py +128 -28
  34. phoenix_channels_python_client-0.3.1/tests/test_subscriptions.py +445 -0
  35. phoenix_channels_python_client-0.3.1/tests/test_supervisor_internals.py +178 -0
  36. phoenix_channels_python_client-0.3.1/tests/test_topic_runtime_internals.py +303 -0
  37. phoenix_channels_python_client-0.2.5/phoenix_channels_python_client/client_types.py +0 -102
  38. phoenix_channels_python_client-0.2.5/phoenix_channels_python_client/topic_runtime.py +0 -574
  39. phoenix_channels_python_client-0.2.5/pyproject.toml +0 -52
  40. phoenix_channels_python_client-0.2.5/tests/test_internal_components.py +0 -1117
  41. phoenix_channels_python_client-0.2.5/tests/test_reconnect_policy_invariants.py +0 -124
  42. phoenix_channels_python_client-0.2.5/tests/test_reconnect_stress.py +0 -136
  43. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/LICENSE +0 -0
  44. {phoenix_channels_python_client-0.2.5 → phoenix_channels_python_client-0.3.1}/MANIFEST.in +0 -0
  45. {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
  46. {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
  47. {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.2.5
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
- python3 -m venv venv
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
- - 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.
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 a running callback may finish before a rejoin cancels it |
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**: 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:
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
- **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.
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
- python3 -m venv venv
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
- - 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.
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 a running callback may finish before a rejoin cancels it |
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**: 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:
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
- **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.
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.2.5"
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
- ClientState,
15
- ReconnectPolicy,
16
- reconnect_policy_is_invalid,
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
- PHXProtocolHandler,
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, api_key: str, vsn: 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) for key, value in query_params if key not in {"api_key", "vsn"}
47
- ]
48
- with_auth = [*filtered, ("api_key", api_key), ("vsn", vsn)]
49
-
50
- connect_url = urlunsplit(
51
- (
52
- split_url.scheme,
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
- redacted_url = urlunsplit(
60
- (
61
- split_url.scheme,
62
- split_url.netloc,
63
- split_url.path,
64
- urlencode(
65
- [
66
- (key, "***" if key == "api_key" else value)
67
- for key, value in with_auth
68
- ]
69
- ),
70
- split_url.fragment,
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 = PhoenixChannelsProtocolVersion.V2,
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.logger = logger
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
- try:
109
- validate_reconnect_policy(reconnect_policy or ReconnectPolicy())
110
- except ValueError as exc:
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
- @staticmethod
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