phoenix-channels-python-client 0.4.3__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.
Files changed (75) hide show
  1. phoenix_channels_python_client-0.5.0/PKG-INFO +745 -0
  2. phoenix_channels_python_client-0.5.0/README.md +712 -0
  3. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/__init__.py +57 -0
  4. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/channel.py +96 -0
  5. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/client.py +113 -102
  6. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/client_types.py +13 -5
  7. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/codec.py +208 -0
  8. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/default_decider.py +38 -40
  9. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/diagnostics.py +4 -2
  10. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/exceptions.py +14 -0
  11. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/messages.py +66 -0
  12. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/protocol.py +18 -0
  13. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/protocol_handler.py +127 -0
  14. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/py.typed +0 -0
  15. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/reconnect_controller.py +7 -7
  16. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/replies.py +32 -0
  17. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/send_session.py +180 -0
  18. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/shutdown_signals.py +16 -16
  19. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/supervisor.py +153 -119
  20. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/topic_runtime.py +1106 -0
  21. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client/topic_subscription.py +122 -0
  22. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/utils.py +12 -30
  23. phoenix_channels_python_client-0.5.0/phoenix_channels_python_client.egg-info/PKG-INFO +745 -0
  24. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client.egg-info/SOURCES.txt +16 -1
  25. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/pyproject.toml +6 -1
  26. phoenix_channels_python_client-0.5.0/tests/test_binary.py +291 -0
  27. phoenix_channels_python_client-0.5.0/tests/test_channel_api.py +229 -0
  28. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_channel_recovery.py +90 -62
  29. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_cleanup_ownership.py +13 -7
  30. phoenix_channels_python_client-0.5.0/tests/test_codec.py +293 -0
  31. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_connection.py +178 -21
  32. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_heartbeat.py +4 -4
  33. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_heartbeat_timeout.py +53 -54
  34. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_join_installation.py +156 -158
  35. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_join_lifecycle.py +232 -92
  36. phoenix_channels_python_client-0.5.0/tests/test_join_params.py +386 -0
  37. phoenix_channels_python_client-0.5.0/tests/test_join_params_serialization.py +124 -0
  38. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_join_responses.py +29 -21
  39. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_optional_lifecycle.py +60 -41
  40. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_optional_reconnect.py +33 -25
  41. phoenix_channels_python_client-0.5.0/tests/test_package_typing.py +84 -0
  42. phoenix_channels_python_client-0.5.0/tests/test_protocol_handler.py +159 -0
  43. phoenix_channels_python_client-0.5.0/tests/test_public_api.py +104 -0
  44. phoenix_channels_python_client-0.5.0/tests/test_push.py +650 -0
  45. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_reconnect.py +133 -112
  46. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_reconnect_decider.py +18 -19
  47. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_reconnect_policy_invariants.py +7 -6
  48. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_reconnect_stress.py +7 -8
  49. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_run_forever_signals.py +5 -5
  50. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_security.py +106 -19
  51. phoenix_channels_python_client-0.5.0/tests/test_send_session.py +335 -0
  52. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_shutdown_races.py +150 -40
  53. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_subscriptions.py +185 -160
  54. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_supervisor_internals.py +34 -36
  55. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_topic_runtime_internals.py +85 -93
  56. phoenix_channels_python_client-0.4.3/PKG-INFO +0 -517
  57. phoenix_channels_python_client-0.4.3/README.md +0 -484
  58. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/__init__.py +0 -53
  59. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/exceptions.py +0 -10
  60. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/phx_messages.py +0 -65
  61. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/protocol_handler.py +0 -255
  62. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/topic_runtime.py +0 -919
  63. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client/topic_subscription.py +0 -74
  64. phoenix_channels_python_client-0.4.3/phoenix_channels_python_client.egg-info/PKG-INFO +0 -517
  65. phoenix_channels_python_client-0.4.3/tests/test_protocol_handler.py +0 -228
  66. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/LICENSE +0 -0
  67. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/MANIFEST.in +0 -0
  68. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/authentication.py +0 -0
  69. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client/client_state_machine.py +0 -0
  70. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  71. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  72. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  73. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/setup.cfg +0 -0
  74. {phoenix_channels_python_client-0.4.3 → phoenix_channels_python_client-0.5.0}/tests/test_cancel_and_wait.py +0 -0
  75. {phoenix_channels_python_client-0.4.3 → 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
+ [![PyPI version](https://img.shields.io/pypi/v/phoenix-channels-python-client.svg)](https://pypi.org/project/phoenix-channels-python-client/)
37
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.