phoenix-channels-python-client 0.4.4__tar.gz → 0.5.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.5.0/PKG-INFO +745 -0
- phoenix_channels_python_client-0.5.0/README.md +712 -0
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/__init__.py +57 -0
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/channel.py +96 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/client.py +111 -101
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/client_types.py +13 -5
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/codec.py +208 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/default_decider.py +38 -40
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/diagnostics.py +4 -2
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/exceptions.py +14 -0
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/messages.py +66 -0
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/protocol.py +18 -0
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/protocol_handler.py +127 -0
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/py.typed +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/reconnect_controller.py +7 -7
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/replies.py +32 -0
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/send_session.py +180 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/shutdown_signals.py +16 -16
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/supervisor.py +153 -119
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/topic_runtime.py +1106 -0
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/topic_subscription.py +122 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/utils.py +12 -30
- phoenix_channels_python_client-0.5.0/phoenix_channels_python_client.egg-info/PKG-INFO +745 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client.egg-info/SOURCES.txt +16 -1
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/pyproject.toml +6 -1
- phoenix_channels_python_client-0.5.0/tests/test_binary.py +291 -0
- phoenix_channels_python_client-0.5.0/tests/test_channel_api.py +229 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_channel_recovery.py +90 -62
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_cleanup_ownership.py +13 -7
- phoenix_channels_python_client-0.5.0/tests/test_codec.py +293 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_connection.py +178 -21
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_heartbeat.py +4 -4
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_heartbeat_timeout.py +53 -54
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_join_installation.py +156 -158
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_join_lifecycle.py +232 -92
- phoenix_channels_python_client-0.5.0/tests/test_join_params.py +386 -0
- phoenix_channels_python_client-0.5.0/tests/test_join_params_serialization.py +124 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_join_responses.py +29 -21
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_optional_lifecycle.py +60 -41
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_optional_reconnect.py +33 -25
- phoenix_channels_python_client-0.5.0/tests/test_package_typing.py +84 -0
- phoenix_channels_python_client-0.5.0/tests/test_protocol_handler.py +159 -0
- phoenix_channels_python_client-0.5.0/tests/test_public_api.py +104 -0
- phoenix_channels_python_client-0.5.0/tests/test_push.py +650 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_reconnect.py +133 -112
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_reconnect_decider.py +18 -19
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_reconnect_policy_invariants.py +7 -6
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_reconnect_stress.py +7 -8
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_run_forever_signals.py +5 -5
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_security.py +106 -19
- phoenix_channels_python_client-0.5.0/tests/test_send_session.py +335 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_shutdown_races.py +52 -49
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_subscriptions.py +185 -160
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_supervisor_internals.py +34 -36
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_topic_runtime_internals.py +85 -93
- phoenix_channels_python_client-0.4.4/PKG-INFO +0 -517
- phoenix_channels_python_client-0.4.4/README.md +0 -484
- phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/__init__.py +0 -53
- phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/exceptions.py +0 -10
- phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/phx_messages.py +0 -65
- phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/protocol_handler.py +0 -255
- phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/topic_runtime.py +0 -919
- phoenix_channels_python_client-0.4.4/phoenix_channels_python_client/topic_subscription.py +0 -74
- phoenix_channels_python_client-0.4.4/phoenix_channels_python_client.egg-info/PKG-INFO +0 -517
- phoenix_channels_python_client-0.4.4/tests/test_protocol_handler.py +0 -228
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/LICENSE +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/authentication.py +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.4.4 → phoenix_channels_python_client-0.5.0}/tests/test_client_state.py +0 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phoenix-channels-python-client
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
|
+
Author: Phoenix Channels Python Client
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pydantic>=2.8
|
|
18
|
+
Requires-Dist: websockets>=14.2
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
|
|
22
|
+
Requires-Dist: looptime<0.10,>=0.7; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: commitizen>=3.13.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pyrefly; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest; extra == "test"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
31
|
+
Requires-Dist: looptime<0.10,>=0.7; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# Phoenix Channels Python Client
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/phoenix-channels-python-client/)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
A modern, async Python client library for connecting to [Phoenix Channels](https://hexdocs.pm/phoenix/channels.html) - the real-time WebSocket layer of the Phoenix Framework.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
### For Users
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install phoenix-channels-python-client
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or from source:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone https://github.com/band-ai/phoenix-channels-python-client.git
|
|
54
|
+
cd phoenix-channels-python-client
|
|
55
|
+
python3 -m venv venv
|
|
56
|
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
57
|
+
pip install .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### For Development
|
|
61
|
+
|
|
62
|
+
Set up development environment:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git clone https://github.com/band-ai/phoenix-channels-python-client.git
|
|
66
|
+
cd phoenix-channels-python-client
|
|
67
|
+
uv sync --extra dev
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Run tests:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uv run pytest
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Dependencies
|
|
77
|
+
|
|
78
|
+
- Python 3.11+
|
|
79
|
+
- `pydantic>=2.8`
|
|
80
|
+
- `websockets>=14.2`
|
|
81
|
+
|
|
82
|
+
## Public API migration
|
|
83
|
+
|
|
84
|
+
The public names have changed without compatibility aliases:
|
|
85
|
+
|
|
86
|
+
| Previous name | Current name |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `PHXChannelsClient` | `Client` |
|
|
89
|
+
| `PHXClientError` | `ClientError` |
|
|
90
|
+
| `PHXTopicError` | `ChannelError` |
|
|
91
|
+
| `PHXConnectionError` | `SocketError` |
|
|
92
|
+
| `PhoenixChannelsProtocolVersion` | `ProtocolVersion` |
|
|
93
|
+
| `PHXEvent` | `SystemEvent` |
|
|
94
|
+
| `ChannelMessage` | `Message` |
|
|
95
|
+
| `phx_messages` module | `messages` module |
|
|
96
|
+
| `Client(url, key)` | `Client(url, api_key=key)` |
|
|
97
|
+
| `setup_logging(level)` | Standard-library logging configuration (see [Logging](#logging)) |
|
|
98
|
+
| `PHXProtocolHandler` | Internal protocol implementation; no public replacement |
|
|
99
|
+
| `get_protocol_handler()` | Removed; protocol parsing and serialization are internal |
|
|
100
|
+
| `Event` | Use `str` or `SystemEvent` for event names |
|
|
101
|
+
| `__author__` | Removed; no public replacement |
|
|
102
|
+
|
|
103
|
+
Import public types from `phoenix_channels_python_client`. `Message` is an
|
|
104
|
+
annotation/result union, not a model constructor. The installed package ships
|
|
105
|
+
`py.typed` for type checkers.
|
|
106
|
+
`SystemEvent` is a `StrEnum`, so members compare directly with their wire strings.
|
|
107
|
+
|
|
108
|
+
Create a `Channel`, register handlers, then join it. Retain the handle for
|
|
109
|
+
readiness, handler changes and cleanup; the topic-string client methods below
|
|
110
|
+
have been removed without compatibility aliases.
|
|
111
|
+
|
|
112
|
+
| Previous method | Channel replacement |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `subscribe_to_topic(topic, callback)` | `room = client.channel(topic)`; `room.on_any(callback)`; `await room.join()` or `async with room:` |
|
|
115
|
+
| `unsubscribe_from_topic(topic)` | `await room.leave()` |
|
|
116
|
+
| `is_topic_joined(topic)` | `room.joined` |
|
|
117
|
+
| `add_event_handler(topic, event, handler)` | `room.on(event, handler)` or `@room.on(event)` |
|
|
118
|
+
| `remove_event_handler(topic, event)` | `room.off(event)` |
|
|
119
|
+
| `set_message_handler(topic, handler)` | `room.on_any(handler)` or `@room.on_any` |
|
|
120
|
+
| `remove_message_handler(topic)` | `room.off_any()` |
|
|
121
|
+
| `get_current_subscriptions()` | Retain your channel handles; no replacement collection getter |
|
|
122
|
+
| `get_event_handler`, `has_event_handler`, `list_event_handlers` | Retain your handlers; no replacement introspection methods |
|
|
123
|
+
| `get_message_handler`, `has_message_handler` | Retain your handlers; no replacement introspection methods |
|
|
124
|
+
|
|
125
|
+
## Quick Start
|
|
126
|
+
|
|
127
|
+
Create the channel, register its handlers, then join with `async with`:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import asyncio
|
|
131
|
+
|
|
132
|
+
from phoenix_channels_python_client import Client, Payload
|
|
133
|
+
|
|
134
|
+
async def main() -> None:
|
|
135
|
+
async with Client(
|
|
136
|
+
"ws://localhost:4000/socket/websocket", api_key="your-api-key"
|
|
137
|
+
) as client:
|
|
138
|
+
room = client.channel("room:lobby")
|
|
139
|
+
|
|
140
|
+
@room.on("new_msg")
|
|
141
|
+
async def handle_message(payload: Payload) -> None:
|
|
142
|
+
if isinstance(payload, bytes):
|
|
143
|
+
print(f"Received binary: {payload.hex()}")
|
|
144
|
+
else:
|
|
145
|
+
print(f"Received: {payload}")
|
|
146
|
+
|
|
147
|
+
async with room:
|
|
148
|
+
await client.run_forever()
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
asyncio.run(main())
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`client.channel(...)` is synchronous, returns an unjoined `Channel`, and sends
|
|
155
|
+
nothing. Handlers registered before entry are ready for the first event after
|
|
156
|
+
the join reply. Application queues still drop their oldest event at capacity,
|
|
157
|
+
and recovery can discard undelivered events; this is not a lossless stream.
|
|
158
|
+
|
|
159
|
+
Entering `async with room:` joins; exiting leaves, including on an exception or
|
|
160
|
+
cancellation. With the default `run_forever()`, Ctrl+C or SIGTERM stops the client.
|
|
161
|
+
The previous signal handlers are restored before a second signal is handled.
|
|
162
|
+
|
|
163
|
+
### Owning process signals yourself
|
|
164
|
+
|
|
165
|
+
If your application owns its process signals (a service, desktop app or test
|
|
166
|
+
runner), call `client.run_forever(install_signal_handlers=False)`. It then
|
|
167
|
+
leaves signal handlers alone; stop the client by scheduling
|
|
168
|
+
`client.shutdown(reason)` on the client's event loop, and `run_forever()`
|
|
169
|
+
returns `None`:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
loop = asyncio.get_running_loop()
|
|
173
|
+
async with asyncio.TaskGroup() as tasks:
|
|
174
|
+
loop.add_signal_handler(
|
|
175
|
+
signal.SIGTERM, lambda: tasks.create_task(client.shutdown("SIGTERM"))
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
await client.run_forever(install_signal_handlers=False)
|
|
179
|
+
finally:
|
|
180
|
+
# Once the group exits, a later SIGTERM must not reach the closed group.
|
|
181
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
From a plain `signal.signal` handler or another thread, use
|
|
185
|
+
`loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
|
|
186
|
+
doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
|
|
187
|
+
network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
|
|
188
|
+
|
|
189
|
+
With the default, handlers are installed only when `run_forever()` runs on the
|
|
190
|
+
main thread; elsewhere it just waits. A handler you registered with
|
|
191
|
+
`loop.add_signal_handler` before calling `run_forever()` still fires for the
|
|
192
|
+
same signal; one registered while it runs replaces the client's.
|
|
193
|
+
|
|
194
|
+
## Phoenix System Events
|
|
195
|
+
|
|
196
|
+
Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
|
|
197
|
+
`phx_error`. Don't use these names for your own events.
|
|
198
|
+
|
|
199
|
+
- The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
|
|
200
|
+
- The client handles `phx_error` and `phx_close` itself, and neither reaches
|
|
201
|
+
your handlers: a crashed channel is rejoined, and a channel the server
|
|
202
|
+
closes loses its topic (see [Channel recovery](#channel-recovery)).
|
|
203
|
+
- `channel.on(...)` raises `ValueError` for `SystemEvent.error` and
|
|
204
|
+
`SystemEvent.close`. Use `on_topic_lost` and `channel.joined` instead.
|
|
205
|
+
|
|
206
|
+
## Protocol Versions
|
|
207
|
+
|
|
208
|
+
Phoenix Channels supports two protocol versions. Choose based on your Phoenix server version:
|
|
209
|
+
|
|
210
|
+
### Protocol v2.0 (Default)
|
|
211
|
+
```python
|
|
212
|
+
from phoenix_channels_python_client import Client
|
|
213
|
+
|
|
214
|
+
async with Client(
|
|
215
|
+
websocket_url="ws://localhost:4000/socket/websocket",
|
|
216
|
+
api_key="your-api-key"
|
|
217
|
+
# protocol_version defaults to v2.0
|
|
218
|
+
) as client:
|
|
219
|
+
... # your code here
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Protocol v1.0
|
|
223
|
+
```python
|
|
224
|
+
from phoenix_channels_python_client import Client, ProtocolVersion
|
|
225
|
+
|
|
226
|
+
async with Client(
|
|
227
|
+
websocket_url="ws://localhost:4000/socket/websocket",
|
|
228
|
+
api_key="your-api-key",
|
|
229
|
+
protocol_version=ProtocolVersion.V1
|
|
230
|
+
) as client:
|
|
231
|
+
... # your code here
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The client accepts `ProtocolVersion.V1`, `ProtocolVersion.V2`, or their exact
|
|
235
|
+
string values `"1.0"` and `"2.0"`. Other values raise `ValueError`. It adds
|
|
236
|
+
`vsn=2.0.0` (V2) or `vsn=1.0.0` (V1) to the socket URL.
|
|
237
|
+
|
|
238
|
+
## Usage Examples
|
|
239
|
+
|
|
240
|
+
### Basic Topic Subscription
|
|
241
|
+
|
|
242
|
+
Use `on_any` when you need the complete `Message`:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
import asyncio
|
|
246
|
+
|
|
247
|
+
from phoenix_channels_python_client import Client, Message
|
|
248
|
+
|
|
249
|
+
async def main() -> None:
|
|
250
|
+
async with Client("ws://localhost:4000/socket/websocket", api_key="api-key") as client:
|
|
251
|
+
room = client.channel("chat:general")
|
|
252
|
+
|
|
253
|
+
@room.on_any
|
|
254
|
+
async def message_handler(message: Message) -> None:
|
|
255
|
+
print(f"Topic: {message.topic}")
|
|
256
|
+
print(f"Event: {message.event}")
|
|
257
|
+
print(f"Payload: {message.payload}")
|
|
258
|
+
|
|
259
|
+
async with room:
|
|
260
|
+
await client.run_forever()
|
|
261
|
+
|
|
262
|
+
asyncio.run(main())
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Message Handlers vs Event-Specific Handlers
|
|
266
|
+
|
|
267
|
+
A channel supports one catch-all handler and one handler per event:
|
|
268
|
+
|
|
269
|
+
| Registration | Handler receives | Removal |
|
|
270
|
+
| --- | --- | --- |
|
|
271
|
+
| `@room.on_any` or `room.on_any(handler)` | Full `Message` | `room.off_any()` |
|
|
272
|
+
| `@room.on("new_msg")` or `room.on("new_msg", handler)` | `Payload` (dict or bytes) | `room.off("new_msg")` |
|
|
273
|
+
|
|
274
|
+
Both registration forms return the handler unchanged. Registering again replaces
|
|
275
|
+
the previous handler for that slot. Handlers must be async functions. `Payload`
|
|
276
|
+
is the public alias `dict[str, Any] | bytes`; narrow it before accessing dict
|
|
277
|
+
fields or decoding application bytes.
|
|
278
|
+
|
|
279
|
+
For each dispatched event, the catch-all runs first, then its event-specific
|
|
280
|
+
handler. Handler selection is captured for that dispatch: changes apply to later
|
|
281
|
+
dispatches, while teardown or cancellation can interrupt the current one. You may
|
|
282
|
+
add or remove handlers before joining or while the channel is active. Removal is
|
|
283
|
+
idempotent; it does not leave the channel.
|
|
284
|
+
|
|
285
|
+
### Event-Specific Handlers
|
|
286
|
+
|
|
287
|
+
Register event decorators before joining:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
import asyncio
|
|
291
|
+
|
|
292
|
+
from phoenix_channels_python_client import Client, Payload
|
|
293
|
+
|
|
294
|
+
async def main() -> None:
|
|
295
|
+
async with Client("ws://localhost:4000/socket/websocket", api_key="api-key") as client:
|
|
296
|
+
room = client.channel("room:lobby")
|
|
297
|
+
|
|
298
|
+
@room.on("user_join")
|
|
299
|
+
async def handle_user_join(payload: Payload) -> None:
|
|
300
|
+
if isinstance(payload, bytes):
|
|
301
|
+
print(f"Binary join notice: {payload.hex()}")
|
|
302
|
+
else:
|
|
303
|
+
print(f"User joined: {payload}")
|
|
304
|
+
|
|
305
|
+
@room.on("user_leave")
|
|
306
|
+
async def handle_user_leave(payload: Payload) -> None:
|
|
307
|
+
if isinstance(payload, bytes):
|
|
308
|
+
print(f"Binary leave notice: {payload.hex()}")
|
|
309
|
+
else:
|
|
310
|
+
print(f"User left: {payload}")
|
|
311
|
+
|
|
312
|
+
async with room:
|
|
313
|
+
await client.run_forever()
|
|
314
|
+
|
|
315
|
+
asyncio.run(main())
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Unsubscribing from Topics
|
|
319
|
+
|
|
320
|
+
Leaving a channel stops delivery and cleans up its membership. Prefer a context
|
|
321
|
+
manager so exceptions and cancellation also trigger cleanup:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
import asyncio
|
|
325
|
+
|
|
326
|
+
from phoenix_channels_python_client import Client, Message
|
|
327
|
+
|
|
328
|
+
async def main() -> None:
|
|
329
|
+
async with Client("ws://localhost:4000/socket/websocket", api_key="api-key") as client:
|
|
330
|
+
room = client.channel("room:lobby")
|
|
331
|
+
|
|
332
|
+
@room.on_any
|
|
333
|
+
async def on_message(message: Message) -> None:
|
|
334
|
+
print(f"Message: {message.payload}")
|
|
335
|
+
|
|
336
|
+
async with room:
|
|
337
|
+
await asyncio.sleep(5)
|
|
338
|
+
|
|
339
|
+
await room.leave() # Repeated leave is harmless.
|
|
340
|
+
print(f"Left {room.topic}; joined={room.joined}")
|
|
341
|
+
|
|
342
|
+
asyncio.run(main())
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
For manual ownership, call `await room.join()` and ensure `await room.leave()`
|
|
346
|
+
runs in your cleanup path. A left handle cannot join again; create a new channel.
|
|
347
|
+
`room.joined` is false during leave or recovery, and `room.topic` remains available.
|
|
348
|
+
A successful join confirms its reply was installed; a later server failure can
|
|
349
|
+
already have changed readiness by the time your code resumes.
|
|
350
|
+
|
|
351
|
+
Join rejection and timeout raise `ChannelError`; joining without a connected
|
|
352
|
+
client raises `SocketError`. A second live channel for the same topic raises
|
|
353
|
+
`ChannelError`. Duplicate/disconnected attempts rejected before admission leave
|
|
354
|
+
the created handle usable. A failed admitted initial join retires that handle.
|
|
355
|
+
|
|
356
|
+
Leave is idempotent, including while joining, rejoining or disconnected. A live
|
|
357
|
+
membership sends a JSON leave and uses `leave_timeout_s`; failure can raise
|
|
358
|
+
`ChannelError` or `SocketError`. Cleanup remains owned if its waiter is cancelled.
|
|
359
|
+
If cleanup cannot confirm that the old membership ended, its topic can stay
|
|
360
|
+
reserved until a terminal control or connection end makes reuse safe. A new handle
|
|
361
|
+
for that topic then raises `ChannelError`; unrelated channels remain usable.
|
|
362
|
+
|
|
363
|
+
### Join parameters
|
|
364
|
+
|
|
365
|
+
`params` follows Phoenix's name for the join payload. Pass a dict, `None` for `{}`,
|
|
366
|
+
or a synchronous callable returning a dict:
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
room = client.channel("room:lobby", params={"locale": "en"})
|
|
370
|
+
room = client.channel("room:private", params=lambda: {"token": fresh_token()})
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The callable is evaluated once for each actual initial join or rejoin, allowing
|
|
374
|
+
fresh credentials after reconnect. Creation alone does not call it or send a
|
|
375
|
+
frame. Providers must be nonblocking and return a JSON-serializable dict;
|
|
376
|
+
provider/encoding failure raises `ChannelError` with its cause. A provider failure
|
|
377
|
+
during recovery permanently loses only that channel instead of retrying forever.
|
|
378
|
+
|
|
379
|
+
## Pushing Events
|
|
380
|
+
|
|
381
|
+
Use `await channel.push(event, payload, timeout_s=10.0)` to send an application
|
|
382
|
+
event and wait for its matching reply. Pass a JSON-serializable dict or, on V2,
|
|
383
|
+
`bytes` (see [Binary payloads](#binary-payloads)). `timeout_s` must be finite and
|
|
384
|
+
greater than zero. The default is 10 seconds.
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
import asyncio
|
|
388
|
+
|
|
389
|
+
from phoenix_channels_python_client import Client, Reply
|
|
390
|
+
|
|
391
|
+
async def main() -> None:
|
|
392
|
+
async with Client("ws://localhost:4000/socket/websocket", api_key="api-key") as client:
|
|
393
|
+
room = client.channel("room:lobby")
|
|
394
|
+
async with room:
|
|
395
|
+
reply: Reply = await room.push("new_msg", {"body": "Hello"}, timeout_s=5.0)
|
|
396
|
+
if reply.status == "ok":
|
|
397
|
+
print(reply.response)
|
|
398
|
+
else:
|
|
399
|
+
print(f"Server replied {reply.status}: {reply.response}")
|
|
400
|
+
|
|
401
|
+
asyncio.run(main())
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`Reply` is a frozen Pydantic model with `status: str` and
|
|
405
|
+
`response: Payload`. A text reply has a dict response; a binary reply has a bytes
|
|
406
|
+
response. Status values are server-defined: an `"error"` reply
|
|
407
|
+
is returned as a `Reply`, without raising `ChannelError`.
|
|
408
|
+
|
|
409
|
+
| Situation | Result |
|
|
410
|
+
| --- | --- |
|
|
411
|
+
| Channel is not joined, including while rejoining | `ChannelError` immediately, even if the socket is also unavailable |
|
|
412
|
+
| Connection is lost after joined admission, before or during send/wait | `SocketError` |
|
|
413
|
+
| Deadline expires without a reply | `PushTimeoutError` (a `ChannelError` subclass) |
|
|
414
|
+
| Channel crashes, closes, is explicitly left, or has a local processing failure | `ChannelError` |
|
|
415
|
+
| Client is explicitly shut down while a push is pending | `ChannelError` |
|
|
416
|
+
| Server returns an error status | `Reply` with that status and response |
|
|
417
|
+
| Caller is cancelled | `asyncio.CancelledError` |
|
|
418
|
+
|
|
419
|
+
A malformed matching reply raises `ChannelError` with the validation cause.
|
|
420
|
+
Invalid payload encoding raises its validation or encoding exception before
|
|
421
|
+
submission. A timeout after a completed send affects only that push. If a write
|
|
422
|
+
remains stalled at its deadline, the client closes that socket; other pending
|
|
423
|
+
pushes fail with `SocketError` and normal reconnect policy applies. An already
|
|
424
|
+
settled reply or failure keeps its outcome.
|
|
425
|
+
|
|
426
|
+
Pushes are never buffered or replayed after recovery. A timeout or cancellation
|
|
427
|
+
does not establish whether the server executed the request; choose retries at
|
|
428
|
+
the application level.
|
|
429
|
+
|
|
430
|
+
Replies are resolved independently of topic handlers. A handler may await a
|
|
431
|
+
push on its own channel. `push()` does not wait for earlier messages to finish
|
|
432
|
+
handling, so its completion does not imply that earlier callbacks completed.
|
|
433
|
+
Only a reply matching a pending push is consumed by that caller; an unmatched
|
|
434
|
+
or late reply can reach the normal handlers, subject to current membership
|
|
435
|
+
checks.
|
|
436
|
+
|
|
437
|
+
### Binary payloads
|
|
438
|
+
|
|
439
|
+
V2 supports `bytes` for pushes, replies, server events and broadcasts. Dicts use
|
|
440
|
+
JSON text frames in both protocols. Passing bytes on V1 raises `TypeError` before
|
|
441
|
+
submission; the client does not encode, decode or select an application format
|
|
442
|
+
for you. Join parameters and lifecycle control frames remain JSON dicts.
|
|
443
|
+
|
|
444
|
+
For example, the [protobuf wire format](https://protobuf.dev/programming-guides/encoding/)
|
|
445
|
+
for `message Update { string body = 1; }` serializes `body="hello"` to the bytes below. With generated protobuf classes,
|
|
446
|
+
pass `update.SerializeToString()` and parse a bytes response with your generated
|
|
447
|
+
class. This example uses the wire bytes directly, so it needs no protobuf
|
|
448
|
+
dependency:
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
import asyncio
|
|
452
|
+
|
|
453
|
+
from phoenix_channels_python_client import Client, ProtocolVersion
|
|
454
|
+
|
|
455
|
+
async def main() -> None:
|
|
456
|
+
async with Client(
|
|
457
|
+
"ws://localhost:4000/socket/websocket",
|
|
458
|
+
api_key="api-key",
|
|
459
|
+
protocol_version=ProtocolVersion.V2,
|
|
460
|
+
) as client:
|
|
461
|
+
room = client.channel("room:lobby")
|
|
462
|
+
async with room:
|
|
463
|
+
encoded_update = b"\x0a\x05hello"
|
|
464
|
+
reply = await room.push("update", encoded_update)
|
|
465
|
+
if isinstance(reply.response, bytes):
|
|
466
|
+
print(f"Protobuf response: {reply.response.hex()}")
|
|
467
|
+
else:
|
|
468
|
+
print(f"JSON response: {reply.response}")
|
|
469
|
+
|
|
470
|
+
asyncio.run(main())
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Event handlers and `Message.payload` also receive raw bytes for binary server
|
|
474
|
+
events and broadcasts. A broadcast can have no `join_ref` and is delivered to
|
|
475
|
+
its current topic. The application chooses the payload decoder; binary data
|
|
476
|
+
is never interpreted as JSON merely because it contains JSON-looking bytes.
|
|
477
|
+
|
|
478
|
+
## Configuration
|
|
479
|
+
|
|
480
|
+
`api_key` is keyword-only and defaults to `None`. Without an
|
|
481
|
+
authentication header, a supplied key replaces any `api_key` in the URL; omitting
|
|
482
|
+
it (or passing `None`) keeps the URL credential, if present. An empty argument
|
|
483
|
+
(`api_key=""`) raises `ValueError`.
|
|
484
|
+
|
|
485
|
+
Connect without a credential, or keep one already in the URL:
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
async with Client("ws://localhost:4000/socket/websocket") as client:
|
|
489
|
+
...
|
|
490
|
+
|
|
491
|
+
async with Client(
|
|
492
|
+
"wss://server.com/socket/websocket?api_key=your-api-key"
|
|
493
|
+
) as client:
|
|
494
|
+
...
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Only `websocket_url` may be positional; all other constructor options are keyword-only:
|
|
498
|
+
|
|
499
|
+
| Option | Default | Meaning |
|
|
500
|
+
|---|---|---|
|
|
501
|
+
| `protocol_version` | `V2` | Phoenix Channels protocol version |
|
|
502
|
+
| `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
|
|
503
|
+
| `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
|
|
504
|
+
| `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats and timeout enforcement |
|
|
505
|
+
| `heartbeat_dead_threshold_s` | `None` | Optional finite positive acknowledgement-silence limit, greater than the enabled finite interval |
|
|
506
|
+
| `join_timeout_s` | `10.0` | Finite positive join-operation deadline |
|
|
507
|
+
| `leave_timeout_s` | `5.0` | Finite positive leave-operation deadline |
|
|
508
|
+
| `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
|
|
509
|
+
| `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 |
|
|
510
|
+
| `on_reconnect` | `None` | `async () -> None`, called after required first-attempt reconciliation |
|
|
511
|
+
| `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
|
|
512
|
+
| `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
|
|
513
|
+
| `on_topic_lost` | `None` | `async (topic: str, error: Exception) -> None`, called when the client permanently loses a channel you did not leave |
|
|
514
|
+
| `reconnect_decider` | `None` | Synchronous `(event: ReconnectEvent) -> ReconnectDecision`; replaces the built-in reconnect policy (see [Owning the reconnect policy](#owning-the-reconnect-policy)) |
|
|
515
|
+
| `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
|
|
516
|
+
|
|
517
|
+
Exceptions raised in ordinary message and lifecycle callbacks are logged, not raised.
|
|
518
|
+
Join-response installation failures instead fail the join (see below).
|
|
519
|
+
|
|
520
|
+
## Connection Lifecycle and Errors
|
|
521
|
+
|
|
522
|
+
- `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying, except after a refused handshake with HTTP 403, which raises `SocketError`; with `auto_reconnect=False` a failed first connection raises `SocketError`.
|
|
523
|
+
- `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `SocketError` on a terminal close, a refused handshake with HTTP 403, after repeated rapid disconnects, or when the client has never entered `async with`.
|
|
524
|
+
- `channel.join()` raises `ChannelError` on rejection, timeout or duplicate live membership, and `SocketError` when the client is disconnected or the connection fails before admission completes.
|
|
525
|
+
- `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.
|
|
526
|
+
- `await client.shutdown()` uses the default reason `"Client shutdown"`; pass a
|
|
527
|
+
reason to describe an explicit stop.
|
|
528
|
+
- `ClientError`, `ChannelError`, `SocketError` and `PushTimeoutError` are exported
|
|
529
|
+
from the package and defined in `phoenix_channels_python_client.exceptions`.
|
|
530
|
+
`ChannelError` and `SocketError` derive from `ClientError`; `PushTimeoutError`
|
|
531
|
+
derives from `ChannelError`.
|
|
532
|
+
|
|
533
|
+
### Reconnects
|
|
534
|
+
|
|
535
|
+
| Close code or handshake | Behaviour (defaults) |
|
|
536
|
+
|---|---|
|
|
537
|
+
| 1000, 1001 | Reconnect (Phoenix ≥ 1.8.8 sends 1001 for "reconnect expected"); stop with `reconnect_on_normal_close=False` |
|
|
538
|
+
| 1008 | Stop with `SocketError` (`policy_violation_is_terminal=True`) |
|
|
539
|
+
| Handshake refused with HTTP 403 | Stop with `SocketError`; other statuses (401, 5xx, …) retry with the backoff below |
|
|
540
|
+
| 1012 | Reconnect after at least a random 1–5 s |
|
|
541
|
+
| 1013 | Reconnect after at least a random 30–60 s |
|
|
542
|
+
| Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
|
|
543
|
+
|
|
544
|
+
Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `SocketError`. Tune all of this with `ReconnectPolicy`.
|
|
545
|
+
|
|
546
|
+
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:
|
|
547
|
+
|
|
548
|
+
```python
|
|
549
|
+
base = ReconnectPolicy()
|
|
550
|
+
policy = ReconnectPolicy.model_validate(base.model_dump() | {"max_delay_s": 10.0})
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### Owning the reconnect policy
|
|
554
|
+
|
|
555
|
+
Pass `reconnect_decider` to make every reconnect decision yourself. While `auto_reconnect` is on, the client calls it with:
|
|
556
|
+
|
|
557
|
+
- `Connecting()` before every connection attempt, the first included;
|
|
558
|
+
- `Connected(generation)` when a socket is up, before any topic rejoins on it;
|
|
559
|
+
- `Disconnected(close_code, close_reason)` after every close. A forced close (heartbeat expiry, `close_connection()`) has `close_code=None`, since the received code is unreliable then;
|
|
560
|
+
- `ConnectFailed(error)` after every failed attempt, with the raw exception (a refused handshake is a `websockets` `InvalidStatus` carrying the response).
|
|
561
|
+
|
|
562
|
+
It returns a `ReconnectDecision`:
|
|
563
|
+
|
|
564
|
+
- `ReconnectDecision(should_reconnect=True, delay_s=2.0)` after `Disconnected` or `ConnectFailed` waits exactly 2 s before the next attempt; `delay_s` defaults to `0`. `Connecting` and `Connected` only proceed or stop, and a delay there stops the client with `SocketError`.
|
|
565
|
+
- `ReconnectDecision(should_reconnect=False)` stops; `run_forever()` returns `None`, and before the first connection `async with` raises `SocketError`. A stop at `Connected` closes that socket before any rejoin.
|
|
566
|
+
- `ReconnectDecision(should_reconnect=False, terminal_error=SocketError(...))` stops; `run_forever()` (or `async with`, before the first connection) raises that error.
|
|
567
|
+
|
|
568
|
+
The decider replaces the whole built-in policy above, rapid-disconnect suppression included; the built-in policy is itself a decider over the same events. It runs inline on the client's supervisor task, so keep it fast and never block. The client calls the same decider on every `async with` entry, so reset any per-run state yourself; the built-in one is rebuilt each time. If it raises, the client stops with a `SocketError` whose `__cause__` is that exception; if it returns anything but a `ReconnectDecision` (an `async def` decider returns a coroutine), the client stops with a `SocketError` too. It is not asked once `shutdown()` has begun. To end a live connection, call `close_connection()` and stop at the next decision.
|
|
569
|
+
|
|
570
|
+
```python
|
|
571
|
+
def decide(event: ReconnectEvent) -> ReconnectDecision:
|
|
572
|
+
match event:
|
|
573
|
+
case ConnectFailed(error=InvalidStatus(response=response)) if response.status_code == 403:
|
|
574
|
+
return ReconnectDecision(should_reconnect=False, terminal_error=SocketError("refused"))
|
|
575
|
+
case Disconnected() | ConnectFailed():
|
|
576
|
+
return ReconnectDecision(should_reconnect=True, delay_s=1.0)
|
|
577
|
+
case _:
|
|
578
|
+
return ReconnectDecision(should_reconnect=True)
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Channel recovery
|
|
582
|
+
|
|
583
|
+
A channel can crash while the socket stays up. On `phx_error`, the client marks it
|
|
584
|
+
not joined and retries its join on that socket using the `ReconnectPolicy`
|
|
585
|
+
backoff. A missed rejoin reply is transient and retried. A socket disconnect hands
|
|
586
|
+
recovery to the next connection. Only established channels recover automatically;
|
|
587
|
+
a failed initial join belongs to its caller.
|
|
588
|
+
|
|
589
|
+
Rejected rejoins, invalid join params, installation failures and server
|
|
590
|
+
`phx_close` permanently lose that channel. The client calls
|
|
591
|
+
`on_topic_lost(topic, error)` once for permanent loss. Explicit leave, shutdown
|
|
592
|
+
and failed initial join do not call it. Create a fresh handle to replace a lost
|
|
593
|
+
channel after its old cleanup releases the topic.
|
|
594
|
+
|
|
595
|
+
Retain your channel handles and check `room.joined`: it is false while disconnected
|
|
596
|
+
or rejoining. `await room.leave()` also works during recovery and stops future
|
|
597
|
+
rejoin attempts. Client lifecycle callbacks stay constructor options; `shutdown()`
|
|
598
|
+
cancels them, including a callback that itself called `shutdown()`.
|
|
599
|
+
|
|
600
|
+
### Heartbeats
|
|
601
|
+
|
|
602
|
+
The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. By default, missed replies log warnings and sending continues with a new reference each interval.
|
|
603
|
+
|
|
604
|
+
Set `heartbeat_dead_threshold_s` to enable closure after acknowledgement silence:
|
|
605
|
+
|
|
606
|
+
```python
|
|
607
|
+
client = Client(
|
|
608
|
+
websocket_url, api_key=api_key,
|
|
609
|
+
heartbeat_interval_s=30.0,
|
|
610
|
+
heartbeat_dead_threshold_s=60.0,
|
|
611
|
+
)
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
The deadline starts when each socket is adopted, including during rejoin and reconnect callbacks. Only a matching pending reply received strictly before expiry renews it, before `on_heartbeat_ack` runs. Enabled enforcement keeps one outstanding heartbeat, skipping sends until it is acknowledged or expires. WebSocket Pong and unrelated messages do not renew it. Rejoin completion grants no additional grace.
|
|
615
|
+
|
|
616
|
+
Expiry or heartbeat send failure force-closes that socket; the supervisor handles cleanup and reconnect/rejoin according to `auto_reconnect` and the existing policy. The deadline bounds close initiation; the transport has its own close-handshake timeout. `heartbeat_interval_s=None` disables heartbeat activity even with a threshold, but a supplied threshold must still be finite and positive. Long thresholds do not prevent the server from applying its own inactivity timeout.
|
|
617
|
+
|
|
618
|
+
## Logging
|
|
619
|
+
|
|
620
|
+
The library uses loggers under `phoenix_channels_python_client`. Configure
|
|
621
|
+
logging in your application; importing the package does not configure it:
|
|
622
|
+
|
|
623
|
+
```python
|
|
624
|
+
import logging
|
|
625
|
+
|
|
626
|
+
logging.basicConfig(level=logging.INFO)
|
|
627
|
+
logging.getLogger("phoenix_channels_python_client").setLevel(logging.DEBUG)
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
## Examples
|
|
631
|
+
|
|
632
|
+
- [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
|
|
633
|
+
- [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## Security Considerations
|
|
638
|
+
|
|
639
|
+
**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:
|
|
640
|
+
|
|
641
|
+
- Server access logs
|
|
642
|
+
- Reverse proxies and load balancers
|
|
643
|
+
- Network monitoring tools
|
|
644
|
+
|
|
645
|
+
**Recommendations:**
|
|
646
|
+
- Ensure your infrastructure does not log full URLs in production
|
|
647
|
+
- Use short-lived, rotatable tokens rather than long-lived API keys
|
|
648
|
+
- Consider this limitation when evaluating this client for sensitive environments
|
|
649
|
+
|
|
650
|
+
Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
|
|
651
|
+
|
|
652
|
+
No `api_key` argument is needed for header authentication:
|
|
653
|
+
|
|
654
|
+
```python
|
|
655
|
+
async with Client(
|
|
656
|
+
"wss://server.com/socket/websocket",
|
|
657
|
+
additional_headers={"x-api-key": "your-api-key"},
|
|
658
|
+
) as client:
|
|
659
|
+
...
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
**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.
|
|
665
|
+
|
|
666
|
+
### Authentication and diagnostic safety
|
|
667
|
+
|
|
668
|
+
A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
|
|
669
|
+
every connection and reconnect. Embedded API-key parameters are removed; empty
|
|
670
|
+
or conflicting authentication headers and simultaneous token-query authentication
|
|
671
|
+
are rejected before connecting. A JWT header overrides an embedded token and takes
|
|
672
|
+
precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
|
|
673
|
+
is preserved and remains visible to servers and proxies. No fallback is attempted.
|
|
674
|
+
|
|
675
|
+
`client.logger` forwards sanitized connection records without raw arguments,
|
|
676
|
+
exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
|
|
677
|
+
the credential policy to transport errors a caller needs to retain. Caller-visible
|
|
678
|
+
errors may still contain personal data and should not be logged outside this policy.
|
|
679
|
+
|
|
680
|
+
Connection diagnostics omit dynamic strings and transport payloads, including URLs,
|
|
681
|
+
headers, frame bodies, user/topic identities, and untrusted server error details.
|
|
682
|
+
Logs retain static lifecycle descriptions, native connection states,
|
|
683
|
+
and exception types. Application data and caller-visible errors are not changed;
|
|
684
|
+
applications remain responsible for logs outside this connection-owned boundary.
|
|
685
|
+
|
|
686
|
+
### Ordered join responses
|
|
687
|
+
|
|
688
|
+
Pass `on_join_response` when creating a channel to install a snapshot from every
|
|
689
|
+
initial join, socket rejoin and same-socket recovery:
|
|
690
|
+
|
|
691
|
+
```python
|
|
692
|
+
state = None
|
|
693
|
+
|
|
694
|
+
def install_snapshot(response):
|
|
695
|
+
global state
|
|
696
|
+
state = validate_snapshot(response)
|
|
697
|
+
|
|
698
|
+
room = client.channel("room:123", on_join_response=install_snapshot)
|
|
699
|
+
room.on_any(on_message)
|
|
700
|
+
async with room:
|
|
701
|
+
await client.run_forever()
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
`JoinResponseCallback` is exported from the package. The hook receives decoded
|
|
705
|
+
successful `payload.response`; a missing response is `None`. It runs synchronously
|
|
706
|
+
before the channel becomes joined, the join completes, or later events are
|
|
707
|
+
delivered. The client adds no application schema or empty snapshot. Join and leave
|
|
708
|
+
replies are handled separately from the bounded application-event queue.
|
|
709
|
+
|
|
710
|
+
Hooks must be short, nonblocking and return `None`. Async callables, awaitables
|
|
711
|
+
and other non-`None` results fail installation with `ChannelError`, as do hook
|
|
712
|
+
exceptions (including a directly raised `CancelledError`); the original cause is
|
|
713
|
+
preserved. Arbitrary prior hook effects are not rolled back. Do not wait on locks,
|
|
714
|
+
perform I/O, defer installation or change client lifecycle inside the hook. An
|
|
715
|
+
asyncio deadline cannot preempt synchronous code that blocks the loop.
|
|
716
|
+
|
|
717
|
+
Initial failure retires the admitted handle. Installation failure on an established
|
|
718
|
+
channel is reported once through `on_topic_lost`. Queue overflow and recovery can
|
|
719
|
+
still discard application events, so applications needing durable state must
|
|
720
|
+
resynchronize rather than assume replay.
|
|
721
|
+
|
|
722
|
+
Join replies match request refs on both protocols. V2 additionally checks a
|
|
723
|
+
present `join_ref`; a missing join_ref is accepted, including for broadcasts.
|
|
724
|
+
Membership controls use their available refs for correlation. V1 cannot provide
|
|
725
|
+
V2 isolation for arbitrary unsolicited events from old memberships.
|
|
726
|
+
|
|
727
|
+
### Optional reconnect participation
|
|
728
|
+
|
|
729
|
+
`client.channel(topic, *, params=None, on_join_response=None,
|
|
730
|
+
blocks_reconnect=True)` creates a channel that blocks the reconnect barrier by
|
|
731
|
+
default. Required channels reconcile their first socket-rejoin attempt sequentially
|
|
732
|
+
before `on_reconnect`; transient failures continue through recovery/backoff. The
|
|
733
|
+
barrier does not wait for eventual success of every required channel.
|
|
734
|
+
|
|
735
|
+
Set `blocks_reconnect=False` when creating an optional channel. Its owned recovery
|
|
736
|
+
task attempts an immediate join after required first attempts and retries transient
|
|
737
|
+
failures with the same backoff. Required delivery and `on_reconnect` do not wait for
|
|
738
|
+
optional replies, callback drains or retries. Explicit `await room.join()` still
|
|
739
|
+
waits for that channel's reply and installation.
|
|
740
|
+
|
|
741
|
+
`on_reconnect` does not promise optional readiness. Check each handle's `joined`
|
|
742
|
+
property and use `on_topic_lost` to replace permanent losses. Do not blindly create
|
|
743
|
+
replacement channels on every reconnect. Leave, shutdown and another socket
|
|
744
|
+
disconnect stop or hand over recovery; obsolete attempts cannot revive a left
|
|
745
|
+
handle.
|