phoenix-channels-python-client 0.3.0__tar.gz → 0.4.0__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 (41) hide show
  1. {phoenix_channels_python_client-0.3.0/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.0}/PKG-INFO +28 -7
  2. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/README.md +27 -6
  3. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/__init__.py +1 -1
  4. phoenix_channels_python_client-0.4.0/phoenix_channels_python_client/authentication.py +43 -0
  5. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client.py +74 -38
  6. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client_types.py +4 -1
  7. phoenix_channels_python_client-0.4.0/phoenix_channels_python_client/diagnostics.py +131 -0
  8. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/protocol_handler.py +6 -1
  9. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/reconnect_controller.py +43 -18
  10. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/supervisor.py +33 -10
  11. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0/phoenix_channels_python_client.egg-info}/PKG-INFO +28 -7
  12. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/SOURCES.txt +3 -0
  13. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/pyproject.toml +1 -1
  14. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_channel_recovery.py +2 -2
  15. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_connection.py +6 -4
  16. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect.py +111 -5
  17. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect_policy_invariants.py +56 -5
  18. phoenix_channels_python_client-0.4.0/tests/test_security.py +316 -0
  19. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_shutdown_races.py +54 -3
  20. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_supervisor_internals.py +24 -1
  21. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/LICENSE +0 -0
  22. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/MANIFEST.in +0 -0
  23. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client_state_machine.py +0 -0
  24. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/exceptions.py +0 -0
  25. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/phx_messages.py +0 -0
  26. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  27. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/topic_runtime.py +0 -0
  28. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/topic_subscription.py +0 -0
  29. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/utils.py +0 -0
  30. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  31. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  32. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  33. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/setup.cfg +0 -0
  34. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_cancel_and_wait.py +0 -0
  35. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_client_state.py +0 -0
  36. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_heartbeat.py +0 -0
  37. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_protocol_handler.py +0 -0
  38. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect_stress.py +0 -0
  39. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_run_forever_signals.py +0 -0
  40. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_subscriptions.py +0 -0
  41. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_topic_runtime_internals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -310,18 +310,19 @@ Exceptions raised in callbacks are logged, not raised.
310
310
 
311
311
  ## Connection Lifecycle and Errors
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`.
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`.
313
+ - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `PHXConnectionError`; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
314
+ - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
315
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
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.
317
317
  - Exceptions live in `phoenix_channels_python_client.exceptions`.
318
318
 
319
319
  ### Reconnects
320
320
 
321
- | Close code | Behaviour (defaults) |
321
+ | Close code or handshake | Behaviour (defaults) |
322
322
  |---|---|
323
- | 1000, 1001 | Stop, unless `reconnect_on_normal_close=True` |
323
+ | 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
324
324
  | 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
325
+ | Handshake refused with HTTP 403 | Stop with `PHXConnectionError`; other statuses (401, 5xx, …) retry with the backoff below |
325
326
  | 1012 | Reconnect after at least a random 1–5 s |
326
327
  | 1013 | Reconnect after at least a random 30–60 s |
327
328
  | Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
@@ -365,7 +366,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
365
366
 
366
367
  ## Security Considerations
367
368
 
368
- **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:
369
+ **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:
369
370
 
370
371
  - Server access logs
371
372
  - Reverse proxies and load balancers
@@ -376,8 +377,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
376
377
  - Use short-lived, rotatable tokens rather than long-lived API keys
377
378
  - Consider this limitation when evaluating this client for sensitive environments
378
379
 
379
- **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.
380
+ Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
380
381
 
381
382
  ---
382
383
 
383
384
  **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.
385
+
386
+ ### Authentication and diagnostic safety
387
+
388
+ A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
389
+ every connection and reconnect. Embedded API-key parameters are removed; empty
390
+ or conflicting authentication headers and simultaneous token-query authentication
391
+ are rejected before connecting. A JWT header overrides an embedded token and takes
392
+ precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
393
+ is preserved and remains visible to servers and proxies. No fallback is attempted.
394
+
395
+ `client.logger` forwards sanitized connection records without raw arguments,
396
+ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
397
+ the credential policy to transport errors a caller needs to retain. Caller-visible
398
+ errors may still contain personal data and should not be logged outside this policy.
399
+
400
+ Connection diagnostics omit dynamic strings and transport payloads, including URLs,
401
+ headers, frame bodies, user/topic identities, and untrusted server error details.
402
+ Logs retain static lifecycle descriptions, native connection states,
403
+ and exception types. Application data and caller-visible errors are not changed;
404
+ applications remain responsible for logs outside this connection-owned boundary.
@@ -279,18 +279,19 @@ Exceptions raised in callbacks are logged, not raised.
279
279
 
280
280
  ## Connection Lifecycle and Errors
281
281
 
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`.
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`.
282
+ - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `PHXConnectionError`; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
283
+ - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
284
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
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.
286
286
  - Exceptions live in `phoenix_channels_python_client.exceptions`.
287
287
 
288
288
  ### Reconnects
289
289
 
290
- | Close code | Behaviour (defaults) |
290
+ | Close code or handshake | Behaviour (defaults) |
291
291
  |---|---|
292
- | 1000, 1001 | Stop, unless `reconnect_on_normal_close=True` |
292
+ | 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
293
293
  | 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
294
+ | Handshake refused with HTTP 403 | Stop with `PHXConnectionError`; other statuses (401, 5xx, …) retry with the backoff below |
294
295
  | 1012 | Reconnect after at least a random 1–5 s |
295
296
  | 1013 | Reconnect after at least a random 30–60 s |
296
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 |
@@ -334,7 +335,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
334
335
 
335
336
  ## Security Considerations
336
337
 
337
- **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:
338
+ **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:
338
339
 
339
340
  - Server access logs
340
341
  - Reverse proxies and load balancers
@@ -345,8 +346,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
345
346
  - Use short-lived, rotatable tokens rather than long-lived API keys
346
347
  - Consider this limitation when evaluating this client for sensitive environments
347
348
 
348
- **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.
349
+ Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
349
350
 
350
351
  ---
351
352
 
352
353
  **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.
354
+
355
+ ### Authentication and diagnostic safety
356
+
357
+ A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
358
+ every connection and reconnect. Embedded API-key parameters are removed; empty
359
+ or conflicting authentication headers and simultaneous token-query authentication
360
+ are rejected before connecting. A JWT header overrides an embedded token and takes
361
+ precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
362
+ is preserved and remains visible to servers and proxies. No fallback is attempted.
363
+
364
+ `client.logger` forwards sanitized connection records without raw arguments,
365
+ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
366
+ the credential policy to transport errors a caller needs to retain. Caller-visible
367
+ errors may still contain personal data and should not be logged outside this policy.
368
+
369
+ Connection diagnostics omit dynamic strings and transport payloads, including URLs,
370
+ headers, frame bodies, user/topic identities, and untrusted server error details.
371
+ Logs retain static lifecycle descriptions, native connection states,
372
+ and exception types. Application data and caller-visible errors are not changed;
373
+ applications remain responsible for logs outside this connection-owned boundary.
@@ -19,7 +19,7 @@ from phoenix_channels_python_client.protocol_handler import (
19
19
  )
20
20
  from phoenix_channels_python_client.utils import setup_logging
21
21
 
22
- __version__ = "0.3.0"
22
+ __version__ = "0.4.0"
23
23
  __author__ = "Phoenix Channels Python Client"
24
24
 
25
25
  __all__ = [
@@ -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,13 +5,24 @@ import logging
5
5
  from collections import deque
6
6
  from collections.abc import Awaitable, Callable
7
7
  from types import TracebackType
8
- from typing import Self
8
+ from typing import Self, cast
9
9
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
10
10
 
11
11
  from websockets import ClientConnection
12
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
+ )
13
20
  from phoenix_channels_python_client.client_state_machine import transition_client_state
14
21
  from phoenix_channels_python_client.client_types import ClientState, ReconnectPolicy
22
+ from phoenix_channels_python_client.diagnostics import (
23
+ ConnectionLogger,
24
+ diagnostic_secrets,
25
+ )
15
26
  from phoenix_channels_python_client.exceptions import PHXConnectionError
16
27
  from phoenix_channels_python_client.protocol_handler import (
17
28
  DEFAULT_PROTOCOL_VERSION,
@@ -36,39 +47,56 @@ TopicLostCallback = Callable[[str, Exception], Awaitable[None]]
36
47
 
37
48
 
38
49
  def _build_channel_socket_urls(
39
- 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,
40
55
  ) -> tuple[str, str]:
41
56
  split_url = urlsplit(websocket_url)
42
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")
43
62
  filtered = [
44
- (key, value) for key, value in query_params if key not in {"api_key", "vsn"}
45
- ]
46
- with_auth = [*filtered, ("api_key", api_key), ("vsn", vsn)]
47
-
48
- connect_url = urlunsplit(
49
- (
50
- split_url.scheme,
51
- split_url.netloc,
52
- split_url.path,
53
- urlencode(with_auth),
54
- 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"}
55
70
  )
56
- )
57
- redacted_url = urlunsplit(
58
- (
59
- split_url.scheme,
60
- split_url.netloc,
61
- split_url.path,
62
- urlencode(
63
- [
64
- (key, "***" if key == "api_key" else value)
65
- for key, value in with_auth
66
- ]
67
- ),
68
- 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
+ )
69
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
+ ]
70
99
  )
71
- return connect_url, redacted_url
72
100
 
73
101
 
74
102
  class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
@@ -92,7 +120,10 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
92
120
  on_topic_lost: TopicLostCallback | None = None,
93
121
  additional_headers: dict[str, str] | None = None,
94
122
  ) -> None:
95
- self.logger = logger
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")
@@ -106,21 +137,18 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
106
137
  if callback_drain_timeout_s <= 0:
107
138
  raise ValueError("callback_drain_timeout_s must be > 0")
108
139
 
109
- self._protocol_handler = PHXProtocolHandler(protocol_version)
140
+ self._protocol_handler = PHXProtocolHandler(
141
+ protocol_version, diagnostic_logger=self.logger
142
+ )
110
143
  connect_url, redacted_url = _build_channel_socket_urls(
111
144
  websocket_url=websocket_url,
112
145
  api_key=api_key,
113
146
  vsn=self._protocol_handler.protocol_version.vsn,
147
+ header_auth=select_authentication_header(self.additional_headers),
114
148
  )
115
149
  self.channel_socket_url = connect_url
116
150
  self.channel_socket_url_redacted = redacted_url
117
151
 
118
- # Extra WebSocket handshake headers, re-sent on every (re)connect. Lets a
119
- # caller send the API key as an ``x-api-key`` header instead of (or
120
- # alongside) the URL query, so a trusted proxy can inject/replace it
121
- # in-header and the credential stays out of URL/proxy logs.
122
- self.additional_headers = dict(additional_headers or {})
123
-
124
152
  self.auto_reconnect = auto_reconnect
125
153
  self.reconnect_policy = reconnect_policy or ReconnectPolicy()
126
154
  self.join_timeout_s = join_timeout_s
@@ -152,6 +180,10 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
152
180
  self._lifecycle_tasks: set[asyncio.Task[None]] = set()
153
181
  self._forced_close_pending = False
154
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
+
155
187
  @property
156
188
  def _active_shutdown(self) -> asyncio.Task[None] | None:
157
189
  task = self._shutdown_task
@@ -202,18 +234,22 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
202
234
  if task is None:
203
235
  if self._is_fully_closed:
204
236
  return
237
+ self._request_stop()
205
238
  task = self._shutdown_task = asyncio.create_task(self._shutdown(reason))
206
239
  # Every caller waits for the same shutdown, and one caller's
207
240
  # cancellation never aborts it.
208
241
  await asyncio.shield(task)
209
242
 
210
- async def _shutdown(self, reason: str) -> None:
211
- self.logger.info("Event loop shutting down! reason=%s", reason)
212
-
243
+ def _request_stop(self) -> None:
244
+ # Runs inside the shutdown() call, not in its task: a refusal or close landing
245
+ # before the task's first step must already see the stop.
213
246
  if self._state not in (ClientState.SHUTTING_DOWN, ClientState.CLOSED):
214
247
  self._transition_state(ClientState.SHUTTING_DOWN)
215
248
  self._shutdown_event.set()
216
249
 
250
+ async def _shutdown(self, reason: str) -> None:
251
+ self.logger.info("Event loop shutting down! reason=%s", reason)
252
+
217
253
  await self._unsubscribe_all()
218
254
  # Includes on_topic_lost callbacks still running for topics already gone.
219
255
  await cancel_and_wait(*self._lifecycle_tasks)
@@ -40,7 +40,10 @@ class ReconnectPolicy(BaseModel):
40
40
  factor: PositiveFloat = 2.0
41
41
  max_delay_s: NonNegativeFloat = 30.0
42
42
  stable_reset_s: PositiveFloat = 60.0
43
- reconnect_on_normal_close: bool = False
43
+ # Phoenix >= 1.8.8 closes deliberate disconnects with 1001 ("reconnect
44
+ # expected"), and Bandit answers a close the peer started with 1000, so
45
+ # neither code is a stop signal.
46
+ reconnect_on_normal_close: bool = True
44
47
  policy_violation_is_terminal: bool = True
45
48
  service_restart_min_delay_s: NonNegativeFloat = 1.0
46
49
  service_restart_max_delay_s: NonNegativeFloat = 5.0
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from urllib.parse import parse_qsl, quote, quote_plus, urlsplit
7
+
8
+ from websockets.protocol import State
9
+
10
+ from phoenix_channels_python_client.authentication import (
11
+ API_KEY_PARAMETER,
12
+ TOKEN_PARAMETER,
13
+ AuthenticationHeader,
14
+ )
15
+
16
+ REDACTED = "[REDACTED]"
17
+ PERCENT_ESCAPE = re.compile(r"%[0-9a-fA-F]{2}")
18
+
19
+
20
+ AUTHENTICATION_NAMES = frozenset(
21
+ {
22
+ API_KEY_PARAMETER,
23
+ TOKEN_PARAMETER,
24
+ *AuthenticationHeader,
25
+ "x-token",
26
+ "authorization",
27
+ "cookie",
28
+ }
29
+ )
30
+ AUTHENTICATION_LABEL = re.compile(
31
+ r"(?i)((?:"
32
+ + "|".join(
33
+ re.escape(name) for name in sorted(AUTHENTICATION_NAMES, key=len, reverse=True)
34
+ )
35
+ + r")[\"\']?\s*[:=]\s*[\"\']?(?:(?:Bearer|Basic)\s+)?)([^\s&\"\']+)"
36
+ )
37
+
38
+
39
+ class ConnectionLogger(logging.Logger):
40
+ """Forward only sanitized records; raw transport objects never reach handlers."""
41
+
42
+ def __init__(self, target: logging.Logger, secrets: set[str]) -> None:
43
+ super().__init__(target.name)
44
+ self.target = target
45
+ self.parent = target
46
+ variants = sorted(
47
+ {
48
+ encoded
49
+ for secret in secrets
50
+ if secret
51
+ for encoded in (
52
+ secret,
53
+ quote(secret, safe=""),
54
+ quote(secret),
55
+ quote(secret, safe=";/?:@&=+$,#-_.!~*'()"),
56
+ quote_plus(secret, safe=""),
57
+ json.dumps(secret)[1:-1],
58
+ )
59
+ },
60
+ key=len,
61
+ reverse=True,
62
+ )
63
+
64
+ self.patterns = [
65
+ re.compile(
66
+ PERCENT_ESCAPE.sub(lambda match: f"(?i:{match[0]})", re.escape(secret))
67
+ )
68
+ for secret in variants
69
+ ]
70
+
71
+ def isEnabledFor(self, level: int) -> bool: # noqa: N802 -- logging API
72
+ # Connection loggers are intentionally absent from the global registry,
73
+ # so its cache invalidation cannot track their effective level.
74
+ return (
75
+ not self.disabled
76
+ and not self.target.disabled
77
+ and self.manager.disable < level
78
+ and level >= self.getEffectiveLevel()
79
+ )
80
+
81
+ def redact(self, text: str) -> str:
82
+ for pattern in self.patterns:
83
+ text = pattern.sub(REDACTED, text)
84
+ return AUTHENTICATION_LABEL.sub(lambda match: match[1] + REDACTED, text)
85
+
86
+ def handle(self, record: logging.LogRecord) -> None:
87
+ if self.target.disabled:
88
+ return
89
+ message = str(record.msg)
90
+ if (
91
+ record.msg == "= connection is %s"
92
+ and isinstance(record.args, tuple)
93
+ and len(record.args) == 1
94
+ and isinstance(record.args[0], str)
95
+ and record.args[0] in State.__members__
96
+ ):
97
+ message = record.msg % record.args
98
+ if record.exc_info and record.exc_info[0]:
99
+ message += " [" + record.exc_info[0].__name__ + "]"
100
+ safe_record = logging.LogRecord(
101
+ record.name,
102
+ record.levelno,
103
+ record.pathname,
104
+ record.lineno,
105
+ self.redact(message),
106
+ (),
107
+ None,
108
+ record.funcName,
109
+ )
110
+ filtered = self.target.filter(safe_record)
111
+ if filtered:
112
+ if isinstance(filtered, logging.LogRecord):
113
+ safe_record = filtered
114
+ super().handle(safe_record)
115
+
116
+
117
+ def diagnostic_secrets(url: str, api_key: str, headers: dict[str, str]) -> set[str]:
118
+ """Collect connection credentials without changing authentication bytes."""
119
+ return {
120
+ api_key,
121
+ *(
122
+ value
123
+ for name, value in parse_qsl(urlsplit(url).query)
124
+ if name.casefold() in AUTHENTICATION_NAMES
125
+ ),
126
+ *(
127
+ value
128
+ for name, value in headers.items()
129
+ if name.casefold() in AUTHENTICATION_NAMES
130
+ ),
131
+ }
@@ -9,6 +9,7 @@ from typing import Any, NamedTuple
9
9
 
10
10
  from websockets import ClientConnection
11
11
 
12
+ from phoenix_channels_python_client.diagnostics import ConnectionLogger
12
13
  from phoenix_channels_python_client.phx_messages import (
13
14
  PHOENIX_TOPIC,
14
15
  ChannelMessage,
@@ -110,6 +111,8 @@ class PHXProtocolHandler:
110
111
  self,
111
112
  protocol_version: PhoenixChannelsProtocolVersion
112
113
  | str = DEFAULT_PROTOCOL_VERSION,
114
+ *,
115
+ diagnostic_logger: logging.Logger | None = None,
113
116
  ) -> None:
114
117
  # Non-v2 values select v1 for compatibility with the original client.
115
118
  self.protocol_version = (
@@ -117,7 +120,9 @@ class PHXProtocolHandler:
117
120
  if protocol_version == PhoenixChannelsProtocolVersion.V2
118
121
  else PhoenixChannelsProtocolVersion.V1
119
122
  )
120
- self.logger = logger.getChild("ProtocolHandler")
123
+ self.logger = diagnostic_logger or ConnectionLogger(
124
+ logger.getChild("ProtocolHandler"), set()
125
+ )
121
126
  self.logger.debug(
122
127
  "Initialized PHXProtocolHandler for protocol version %s",
123
128
  self.protocol_version,