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.
- {phoenix_channels_python_client-0.3.0/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.4.0}/PKG-INFO +28 -7
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/README.md +27 -6
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/__init__.py +1 -1
- phoenix_channels_python_client-0.4.0/phoenix_channels_python_client/authentication.py +43 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client.py +74 -38
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client_types.py +4 -1
- phoenix_channels_python_client-0.4.0/phoenix_channels_python_client/diagnostics.py +131 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/protocol_handler.py +6 -1
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/reconnect_controller.py +43 -18
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/supervisor.py +33 -10
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0/phoenix_channels_python_client.egg-info}/PKG-INFO +28 -7
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/SOURCES.txt +3 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/pyproject.toml +1 -1
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_channel_recovery.py +2 -2
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_connection.py +6 -4
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect.py +111 -5
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect_policy_invariants.py +56 -5
- phoenix_channels_python_client-0.4.0/tests/test_security.py +316 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_shutdown_races.py +54 -3
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_supervisor_internals.py +24 -1
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/LICENSE +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/phx_messages.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/shutdown_signals.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/topic_runtime.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/topic_subscription.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client/utils.py +0 -0
- {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
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
- {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
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_client_state.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_heartbeat.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_reconnect_stress.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_run_forever_signals.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.4.0}/tests/test_subscriptions.py +0 -0
- {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
|
+
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
|
|
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 |
|
|
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**:
|
|
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
|
-
|
|
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
|
|
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 |
|
|
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**:
|
|
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
|
-
|
|
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.
|
|
@@ -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,
|
|
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)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|