phoenix-channels-python-client 0.2.4__tar.gz → 0.2.5__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 (29) hide show
  1. {phoenix_channels_python_client-0.2.4/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.2.5}/PKG-INFO +121 -23
  2. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/README.md +118 -20
  3. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/__init__.py +1 -1
  4. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/client.py +52 -36
  5. phoenix_channels_python_client-0.2.5/phoenix_channels_python_client/shutdown_signals.py +119 -0
  6. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/supervisor.py +66 -68
  7. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/topic_runtime.py +4 -11
  8. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/utils.py +19 -0
  9. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5/phoenix_channels_python_client.egg-info}/PKG-INFO +121 -23
  10. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client.egg-info/SOURCES.txt +4 -1
  11. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client.egg-info/requires.txt +2 -2
  12. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/pyproject.toml +3 -3
  13. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/tests/test_internal_components.py +93 -114
  14. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/tests/test_reconnect_stress.py +1 -1
  15. phoenix_channels_python_client-0.2.5/tests/test_run_forever_signals.py +311 -0
  16. phoenix_channels_python_client-0.2.5/tests/test_shutdown_races.py +221 -0
  17. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/LICENSE +0 -0
  18. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/MANIFEST.in +0 -0
  19. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/client_state_machine.py +0 -0
  20. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/client_types.py +0 -0
  21. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/exceptions.py +0 -0
  22. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/phx_messages.py +0 -0
  23. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/protocol_handler.py +0 -0
  24. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/reconnect_controller.py +0 -0
  25. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client/topic_subscription.py +0 -0
  26. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  27. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  28. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/setup.cfg +0 -0
  29. {phoenix_channels_python_client-0.2.4 → phoenix_channels_python_client-0.2.5}/tests/test_reconnect_policy_invariants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -17,14 +17,14 @@ License-File: LICENSE
17
17
  Requires-Dist: websockets>=14.2
18
18
  Provides-Extra: dev
19
19
  Requires-Dist: pytest; extra == "dev"
20
- Requires-Dist: pytest-asyncio; extra == "dev"
20
+ Requires-Dist: pytest-asyncio>=1.4; extra == "dev"
21
21
  Requires-Dist: pre-commit>=3.0.0; extra == "dev"
22
22
  Requires-Dist: commitizen>=3.13.0; extra == "dev"
23
23
  Requires-Dist: pyrefly; extra == "dev"
24
24
  Requires-Dist: ruff; extra == "dev"
25
25
  Provides-Extra: test
26
26
  Requires-Dist: pytest; extra == "test"
27
- Requires-Dist: pytest-asyncio; extra == "test"
27
+ Requires-Dist: pytest-asyncio>=1.4; extra == "test"
28
28
  Dynamic: license-file
29
29
 
30
30
  # Phoenix Channels Python Client
@@ -39,7 +39,11 @@ A modern, async Python client library for connecting to [Phoenix Channels](https
39
39
 
40
40
  ### For Users
41
41
 
42
- Install from source:
42
+ ```bash
43
+ pip install phoenix-channels-python-client
44
+ ```
45
+
46
+ Or from source:
43
47
 
44
48
  ```bash
45
49
  git clone https://github.com/band-ai/phoenix-channels-python-client.git
@@ -65,7 +69,6 @@ pip install -e ".[dev]"
65
69
  Run tests:
66
70
 
67
71
  ```bash
68
- pip install -e ".[test]"
69
72
  pytest
70
73
  ```
71
74
 
@@ -96,26 +99,56 @@ async def main():
96
99
  # Subscribe to a topic
97
100
  await client.subscribe_to_topic("room:lobby", handle_message)
98
101
 
99
- # Use built-in convenience method to keep connection alive
100
- await client.run_forever() # Handles Ctrl+C automatically
102
+ # Ctrl+C or SIGTERM shuts the client down and run_forever() returns.
103
+ # The previous handlers are restored first, so a second signal reaches them.
104
+ await client.run_forever()
101
105
 
102
106
  if __name__ == "__main__":
103
107
  asyncio.run(main())
104
108
  ```
105
109
 
106
- ## Phoenix System Events
110
+ ### Owning process signals yourself
111
+
112
+ If your application owns its process signals (a service, desktop app or test
113
+ runner), call `client.run_forever(install_signal_handlers=False)`. It then
114
+ leaves signal handlers alone; stop the client by scheduling
115
+ `client.shutdown(reason)` on the client's event loop, and `run_forever()`
116
+ returns `None`:
117
+
118
+ ```python
119
+ loop = asyncio.get_running_loop()
120
+ async with asyncio.TaskGroup() as tasks:
121
+ loop.add_signal_handler(
122
+ signal.SIGTERM, lambda: tasks.create_task(client.shutdown("SIGTERM"))
123
+ )
124
+ try:
125
+ await client.run_forever(install_signal_handlers=False)
126
+ finally:
127
+ # Once the group exits, a later SIGTERM must not reach the closed group.
128
+ loop.remove_signal_handler(signal.SIGTERM)
129
+ ```
130
+
131
+ From a plain `signal.signal` handler or another thread, use
132
+ `loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
133
+ doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
134
+ network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
107
135
 
108
- Phoenix Channels uses several reserved event names for internal protocol communication. The client library automatically handles these system events to manage connections, subscriptions, and message routing:
136
+ With the default, handlers are installed only when `run_forever()` runs on the
137
+ main thread; elsewhere it just waits. A handler you registered with
138
+ `loop.add_signal_handler` before calling `run_forever()` still fires for the
139
+ same signal; one registered while it runs replaces the client's.
109
140
 
110
- - `phx_join` - Channel join requests
111
- - `phx_reply` - Server replies to client messages
112
- - `phx_leave` - Channel leave notifications
113
- - `phx_close` - Channel close events
114
- - `phx_error` - Channel error events
141
+ ## Phoenix System Events
115
142
 
116
- **Important:** You should avoid using these event names when handling custom events in your application, as they are reserved for Phoenix's internal protocol. The library uses these events to determine message routing and connection state management.
143
+ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
144
+ `phx_error`. Don't use these names for your own events.
117
145
 
118
- If you have a specific use case that requires handling these system events directly, you can do so, but be aware that this may interfere with the library's automatic connection management.
146
+ - The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
147
+ - A server `phx_close` or `phx_error` reaches your message handler like any
148
+ other message; the client does not rejoin or unsubscribe on its own.
149
+ - For an event-specific handler on these, register `PHXEvent.close` or
150
+ `PHXEvent.error` from `phoenix_channels_python_client.phx_messages`; the
151
+ plain strings don't match.
119
152
 
120
153
  ## Protocol Versions
121
154
 
@@ -130,7 +163,7 @@ async with PHXChannelsClient(
130
163
  api_key="your-api-key"
131
164
  # protocol_version defaults to v2.0
132
165
  ) as client:
133
- # Your code here
166
+ ... # your code here
134
167
  ```
135
168
 
136
169
  ### Protocol v1.0
@@ -142,9 +175,11 @@ async with PHXChannelsClient(
142
175
  api_key="your-api-key",
143
176
  protocol_version=PhoenixChannelsProtocolVersion.V1
144
177
  ) as client:
145
- # Your code here
178
+ ... # your code here
146
179
  ```
147
180
 
181
+ The client adds `vsn=2.0.0` (V2) or `vsn=1.0.0` (V1) to the socket URL.
182
+
148
183
  ## Usage Examples
149
184
 
150
185
  ### Basic Topic Subscription
@@ -186,7 +221,7 @@ The library provides two complementary ways to handle incoming messages:
186
221
  1. **Message handler runs first** (if set) - receives the full message
187
222
  2. **Event-specific handler runs second** (if matching event) - receives just the payload
188
223
 
189
- You can add, remove, or change either type of handler at any time during your connection.
224
+ All handlers must be `async def`. You can add, remove, or change either type of handler at any time during your connection.
190
225
 
191
226
  ### Event-Specific Handlers
192
227
 
@@ -225,10 +260,13 @@ You can unsubscribe from topics to stop receiving messages and clean up resource
225
260
  import asyncio
226
261
  from phoenix_channels_python_client import PHXChannelsClient
227
262
 
263
+ async def on_message(msg):
264
+ print(f"Message: {msg.payload}")
265
+
228
266
  async def main():
229
267
  async with PHXChannelsClient("ws://localhost:4000/socket/websocket", "api-key") as client:
230
268
  # Subscribe to a topic
231
- await client.subscribe_to_topic("room:lobby", lambda msg: print(f"Message: {msg.payload}"))
269
+ await client.subscribe_to_topic("room:lobby", on_message)
232
270
 
233
271
  # Do some work...
234
272
  await asyncio.sleep(5)
@@ -245,13 +283,73 @@ asyncio.run(main())
245
283
 
246
284
  **Notes on unsubscription:**
247
285
  - Removes all handlers (both message and event-specific) for that topic
248
- - Sends a leave message to the Phoenix server
286
+ - Sends `phx_leave` and waits for the reply (`leave_timeout_s`, default 5 s)
287
+ - Raises `PHXTopicError` if the topic isn't subscribed or the leave times out, and `PHXConnectionError` while disconnected
288
+
289
+ ## Configuration
290
+
291
+ Everything after `api_key` is keyword-only:
292
+
293
+ | Option | Default | Meaning |
294
+ |---|---|---|
295
+ | `protocol_version` | `V2` | Phoenix Channels protocol version |
296
+ | `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
297
+ | `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
298
+ | `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
299
+ | `join_timeout_s` | `10.0` | Wait for a join reply |
300
+ | `leave_timeout_s` | `5.0` | Wait for a leave reply |
301
+ | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
302
+ | `callback_drain_timeout_s` | `2.0` | How long a running callback may finish before a rejoin cancels it |
303
+ | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
304
+ | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
305
+ | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
306
+ | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
307
+
308
+ Exceptions raised in callbacks are logged, not raised.
309
+
310
+ ## Connection Lifecycle and Errors
311
+
312
+ - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
313
+ - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
314
+ - `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription.
315
+ - `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on.
316
+ - Exceptions live in `phoenix_channels_python_client.exceptions`.
317
+
318
+ ### Reconnects
319
+
320
+ | Close code | Behaviour (defaults) |
321
+ |---|---|
322
+ | 1000, 1001 | Stop, unless `reconnect_on_normal_close=True` |
323
+ | 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
324
+ | 1012 | Reconnect after at least a random 1–5 s |
325
+ | 1013 | Reconnect after at least a random 30–60 s |
326
+ | Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
327
+
328
+ Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `PHXConnectionError`. Tune all of this with `ReconnectPolicy`.
329
+
330
+ ### Heartbeats
331
+
332
+ The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
333
+
334
+ ## Logging
335
+
336
+ ```python
337
+ import logging
338
+ from phoenix_channels_python_client import setup_logging
339
+
340
+ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_client"
341
+ ```
342
+
343
+ ## Examples
344
+
345
+ - [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
346
+ - [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
249
347
 
250
348
  ---
251
349
 
252
350
  ## Security Considerations
253
351
 
254
- **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). While the connection uses WSS (encrypted in transit), the API key may be logged by:
352
+ **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
255
353
 
256
354
  - Server access logs
257
355
  - Reverse proxies and load balancers
@@ -262,7 +360,7 @@ asyncio.run(main())
262
360
  - Use short-lived, rotatable tokens rather than long-lived API keys
263
361
  - Consider this limitation when evaluating this client for sensitive environments
264
362
 
265
- **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This Python client currently uses the older `params` style for broad compatibility.
363
+ **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
266
364
 
267
365
  ---
268
366
 
@@ -10,7 +10,11 @@ A modern, async Python client library for connecting to [Phoenix Channels](https
10
10
 
11
11
  ### For Users
12
12
 
13
- Install from source:
13
+ ```bash
14
+ pip install phoenix-channels-python-client
15
+ ```
16
+
17
+ Or from source:
14
18
 
15
19
  ```bash
16
20
  git clone https://github.com/band-ai/phoenix-channels-python-client.git
@@ -36,7 +40,6 @@ pip install -e ".[dev]"
36
40
  Run tests:
37
41
 
38
42
  ```bash
39
- pip install -e ".[test]"
40
43
  pytest
41
44
  ```
42
45
 
@@ -67,26 +70,56 @@ async def main():
67
70
  # Subscribe to a topic
68
71
  await client.subscribe_to_topic("room:lobby", handle_message)
69
72
 
70
- # Use built-in convenience method to keep connection alive
71
- await client.run_forever() # Handles Ctrl+C automatically
73
+ # Ctrl+C or SIGTERM shuts the client down and run_forever() returns.
74
+ # The previous handlers are restored first, so a second signal reaches them.
75
+ await client.run_forever()
72
76
 
73
77
  if __name__ == "__main__":
74
78
  asyncio.run(main())
75
79
  ```
76
80
 
77
- ## Phoenix System Events
81
+ ### Owning process signals yourself
82
+
83
+ If your application owns its process signals (a service, desktop app or test
84
+ runner), call `client.run_forever(install_signal_handlers=False)`. It then
85
+ leaves signal handlers alone; stop the client by scheduling
86
+ `client.shutdown(reason)` on the client's event loop, and `run_forever()`
87
+ returns `None`:
88
+
89
+ ```python
90
+ loop = asyncio.get_running_loop()
91
+ async with asyncio.TaskGroup() as tasks:
92
+ loop.add_signal_handler(
93
+ signal.SIGTERM, lambda: tasks.create_task(client.shutdown("SIGTERM"))
94
+ )
95
+ try:
96
+ await client.run_forever(install_signal_handlers=False)
97
+ finally:
98
+ # Once the group exits, a later SIGTERM must not reach the closed group.
99
+ loop.remove_signal_handler(signal.SIGTERM)
100
+ ```
101
+
102
+ From a plain `signal.signal` handler or another thread, use
103
+ `loop.call_soon_threadsafe(...)` instead: a bare `asyncio.create_task` there
104
+ doesn't wake a loop blocked on I/O, so the shutdown can stall until the next
105
+ network activity. See [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py).
78
106
 
79
- Phoenix Channels uses several reserved event names for internal protocol communication. The client library automatically handles these system events to manage connections, subscriptions, and message routing:
107
+ With the default, handlers are installed only when `run_forever()` runs on the
108
+ main thread; elsewhere it just waits. A handler you registered with
109
+ `loop.add_signal_handler` before calling `run_forever()` still fires for the
110
+ same signal; one registered while it runs replaces the client's.
80
111
 
81
- - `phx_join` - Channel join requests
82
- - `phx_reply` - Server replies to client messages
83
- - `phx_leave` - Channel leave notifications
84
- - `phx_close` - Channel close events
85
- - `phx_error` - Channel error events
112
+ ## Phoenix System Events
86
113
 
87
- **Important:** You should avoid using these event names when handling custom events in your application, as they are reserved for Phoenix's internal protocol. The library uses these events to determine message routing and connection state management.
114
+ Phoenix reserves `phx_join`, `phx_reply`, `phx_leave`, `phx_close` and
115
+ `phx_error`. Don't use these names for your own events.
88
116
 
89
- If you have a specific use case that requires handling these system events directly, you can do so, but be aware that this may interfere with the library's automatic connection management.
117
+ - The client sends `phx_join` and `phx_leave` and consumes their `phx_reply`.
118
+ - A server `phx_close` or `phx_error` reaches your message handler like any
119
+ other message; the client does not rejoin or unsubscribe on its own.
120
+ - For an event-specific handler on these, register `PHXEvent.close` or
121
+ `PHXEvent.error` from `phoenix_channels_python_client.phx_messages`; the
122
+ plain strings don't match.
90
123
 
91
124
  ## Protocol Versions
92
125
 
@@ -101,7 +134,7 @@ async with PHXChannelsClient(
101
134
  api_key="your-api-key"
102
135
  # protocol_version defaults to v2.0
103
136
  ) as client:
104
- # Your code here
137
+ ... # your code here
105
138
  ```
106
139
 
107
140
  ### Protocol v1.0
@@ -113,9 +146,11 @@ async with PHXChannelsClient(
113
146
  api_key="your-api-key",
114
147
  protocol_version=PhoenixChannelsProtocolVersion.V1
115
148
  ) as client:
116
- # Your code here
149
+ ... # your code here
117
150
  ```
118
151
 
152
+ The client adds `vsn=2.0.0` (V2) or `vsn=1.0.0` (V1) to the socket URL.
153
+
119
154
  ## Usage Examples
120
155
 
121
156
  ### Basic Topic Subscription
@@ -157,7 +192,7 @@ The library provides two complementary ways to handle incoming messages:
157
192
  1. **Message handler runs first** (if set) - receives the full message
158
193
  2. **Event-specific handler runs second** (if matching event) - receives just the payload
159
194
 
160
- You can add, remove, or change either type of handler at any time during your connection.
195
+ All handlers must be `async def`. You can add, remove, or change either type of handler at any time during your connection.
161
196
 
162
197
  ### Event-Specific Handlers
163
198
 
@@ -196,10 +231,13 @@ You can unsubscribe from topics to stop receiving messages and clean up resource
196
231
  import asyncio
197
232
  from phoenix_channels_python_client import PHXChannelsClient
198
233
 
234
+ async def on_message(msg):
235
+ print(f"Message: {msg.payload}")
236
+
199
237
  async def main():
200
238
  async with PHXChannelsClient("ws://localhost:4000/socket/websocket", "api-key") as client:
201
239
  # Subscribe to a topic
202
- await client.subscribe_to_topic("room:lobby", lambda msg: print(f"Message: {msg.payload}"))
240
+ await client.subscribe_to_topic("room:lobby", on_message)
203
241
 
204
242
  # Do some work...
205
243
  await asyncio.sleep(5)
@@ -216,13 +254,73 @@ asyncio.run(main())
216
254
 
217
255
  **Notes on unsubscription:**
218
256
  - Removes all handlers (both message and event-specific) for that topic
219
- - Sends a leave message to the Phoenix server
257
+ - Sends `phx_leave` and waits for the reply (`leave_timeout_s`, default 5 s)
258
+ - Raises `PHXTopicError` if the topic isn't subscribed or the leave times out, and `PHXConnectionError` while disconnected
259
+
260
+ ## Configuration
261
+
262
+ Everything after `api_key` is keyword-only:
263
+
264
+ | Option | Default | Meaning |
265
+ |---|---|---|
266
+ | `protocol_version` | `V2` | Phoenix Channels protocol version |
267
+ | `auto_reconnect` | `True` | Reconnect and rejoin topics after a disconnect |
268
+ | `reconnect_policy` | `ReconnectPolicy()` | Backoff and close-code handling (see below) |
269
+ | `heartbeat_interval_s` | `30.0` | Heartbeat period; `None` disables heartbeats |
270
+ | `join_timeout_s` | `10.0` | Wait for a join reply |
271
+ | `leave_timeout_s` | `5.0` | Wait for a leave reply |
272
+ | `max_topic_queue_size` | `1000` | Per-topic buffer; the oldest message is dropped when full |
273
+ | `callback_drain_timeout_s` | `2.0` | How long a running callback may finish before a rejoin cancels it |
274
+ | `on_reconnect` | `None` | `async () -> None`, called after topics are rejoined |
275
+ | `on_disconnect` | `None` | `async (error: Exception \| None) -> None`; `None` on a clean close |
276
+ | `on_heartbeat_ack` | `None` | Synchronous `() -> None`; runs on the message path, so keep it fast |
277
+ | `additional_headers` | `None` | Extra handshake headers sent on every (re)connect |
278
+
279
+ Exceptions raised in callbacks are logged, not raised.
280
+
281
+ ## Connection Lifecycle and Errors
282
+
283
+ - `async with` waits for the first connection. With `auto_reconnect=True` it keeps retrying; with `auto_reconnect=False` a failed first connection raises `PHXConnectionError`.
284
+ - `run_forever()` returns `None` after `shutdown()`, a signal, or a close that doesn't reconnect. It raises `PHXConnectionError` on a terminal close, after repeated rapid disconnects, or when the client has never entered `async with`.
285
+ - `subscribe_to_topic()` raises `PHXTopicError` on a rejected join, a join timeout or a duplicate subscription.
286
+ - `await client.close_connection(reason)` force-closes the current connection; the client then reconnects if `auto_reconnect` is on.
287
+ - Exceptions live in `phoenix_channels_python_client.exceptions`.
288
+
289
+ ### Reconnects
290
+
291
+ | Close code | Behaviour (defaults) |
292
+ |---|---|
293
+ | 1000, 1001 | Stop, unless `reconnect_on_normal_close=True` |
294
+ | 1008 | Stop with `PHXConnectionError` (`policy_violation_is_terminal=True`) |
295
+ | 1012 | Reconnect after at least a random 1–5 s |
296
+ | 1013 | Reconnect after at least a random 30–60 s |
297
+ | Anything else, or no close frame | Reconnect with jittered backoff: 0.5 s × 2ⁿ, capped at 30 s, reset after 60 s of uptime |
298
+
299
+ Disconnects within 5 s of connecting count as rapid and get longer minimum delays; ten within 60 s stop the client with `PHXConnectionError`. Tune all of this with `ReconnectPolicy`.
300
+
301
+ ### Heartbeats
302
+
303
+ The client sends a `heartbeat` on the `phoenix` topic every `heartbeat_interval_s`. A missed reply only logs a warning; to act on it, combine `on_heartbeat_ack` with `close_connection()`.
304
+
305
+ ## Logging
306
+
307
+ ```python
308
+ import logging
309
+ from phoenix_channels_python_client import setup_logging
310
+
311
+ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_client"
312
+ ```
313
+
314
+ ## Examples
315
+
316
+ - [`examples/minimal_phx_events_client.py`](examples/minimal_phx_events_client.py): subscribe to a topic and log messages until Ctrl+C or SIGTERM.
317
+ - [`examples/embedded_host_signals.py`](examples/embedded_host_signals.py): run the client in a host that owns its process signals.
220
318
 
221
319
  ---
222
320
 
223
321
  ## Security Considerations
224
322
 
225
- **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). While the connection uses WSS (encrypted in transit), the API key may be logged by:
323
+ **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
226
324
 
227
325
  - Server access logs
228
326
  - Reverse proxies and load balancers
@@ -233,7 +331,7 @@ asyncio.run(main())
233
331
  - Use short-lived, rotatable tokens rather than long-lived API keys
234
332
  - Consider this limitation when evaluating this client for sensitive environments
235
333
 
236
- **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This Python client currently uses the older `params` style for broad compatibility.
334
+ **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
237
335
 
238
336
  ---
239
337
 
@@ -19,7 +19,7 @@ from phoenix_channels_python_client.protocol_handler import (
19
19
  )
20
20
  from phoenix_channels_python_client.utils import setup_logging
21
21
 
22
- __version__ = "0.2.4"
22
+ __version__ = "0.2.5"
23
23
  __author__ = "Phoenix Channels Python Client"
24
24
 
25
25
  __all__ = [
@@ -25,6 +25,7 @@ from phoenix_channels_python_client.reconnect_controller import ReconnectControl
25
25
  from phoenix_channels_python_client.supervisor import SupervisorMixin
26
26
  from phoenix_channels_python_client.topic_runtime import TopicRuntimeMixin
27
27
  from phoenix_channels_python_client.topic_subscription import TopicSubscription
28
+ from phoenix_channels_python_client.utils import cancel_and_wait
28
29
 
29
30
  logger = logging.getLogger(__name__)
30
31
 
@@ -145,6 +146,7 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
145
146
  self._connected_event = asyncio.Event()
146
147
  self._conn_generation = 0
147
148
  self._supervisor_task: asyncio.Task[None] | None = None
149
+ self._shutdown_task: asyncio.Task[None] | None = None
148
150
  self._message_routing_task: asyncio.Task[None] | None = None
149
151
  self._initial_connection_future: asyncio.Future[None] | None = None
150
152
  self._rapid_disconnects: deque[float] = deque()
@@ -157,13 +159,26 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
157
159
  self._on_heartbeat_ack = on_heartbeat_ack
158
160
  self._forced_close_pending = False
159
161
 
162
+ @property
163
+ def _active_shutdown(self) -> asyncio.Task[None] | None:
164
+ task = self._shutdown_task
165
+ return task if task is not None and not task.done() else None
166
+
167
+ @property
168
+ def _is_fully_closed(self) -> bool:
169
+ return (
170
+ self._state == ClientState.CLOSED
171
+ and not self._topic_subscriptions
172
+ and self.connection is None
173
+ )
174
+
160
175
  @staticmethod
161
176
  def reconnect_policy_is_invalid(policy: ReconnectPolicy) -> bool:
162
177
  return reconnect_policy_is_invalid(policy)
163
178
 
164
179
  async def __aenter__(self) -> PHXChannelsClient:
165
180
  self.logger.debug("Entering PHXChannelsClient context")
166
- if self._state != ClientState.CLOSED:
181
+ if self._state != ClientState.CLOSED or self._active_shutdown is not None:
167
182
  raise PHXConnectionError("Client is already running")
168
183
 
169
184
  self._shutdown_event.clear()
@@ -192,51 +207,52 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
192
207
  self.logger.debug("Leaving PHXChannelsClient context")
193
208
  await self.shutdown("Leaving PHXChannelsClient context")
194
209
 
195
- async def shutdown(
196
- self,
197
- reason: str,
198
- ) -> None:
199
- if (
200
- self._state == ClientState.CLOSED
201
- and not self._topic_subscriptions
202
- and self.connection is None
203
- ):
204
- return
205
-
210
+ async def shutdown(self, reason: str) -> None:
211
+ task = self._active_shutdown
212
+ if task is None:
213
+ if self._is_fully_closed:
214
+ return
215
+ task = self._shutdown_task = asyncio.create_task(self._shutdown(reason))
216
+ # Every caller waits for the same shutdown, and one caller's
217
+ # cancellation never aborts it.
218
+ await asyncio.shield(task)
219
+
220
+ async def _shutdown(self, reason: str) -> None:
206
221
  self.logger.info("Event loop shutting down! reason=%s", reason)
207
222
 
208
223
  if self._state not in (ClientState.SHUTTING_DOWN, ClientState.CLOSED):
209
224
  self._transition_state(ClientState.SHUTTING_DOWN)
210
-
211
225
  self._shutdown_event.set()
212
226
 
213
- topics_to_unsubscribe = list(self._topic_subscriptions.keys())
214
- if topics_to_unsubscribe:
215
- unsubscribe_tasks = [
216
- self.unsubscribe_from_topic(topic, _allow_disconnected=True)
217
- for topic in topics_to_unsubscribe
218
- ]
219
- results = await asyncio.gather(*unsubscribe_tasks, return_exceptions=True)
220
-
221
- for topic, result in zip(topics_to_unsubscribe, results, strict=False):
222
- if isinstance(result, Exception):
223
- self.logger.warning(
224
- "Failed to unsubscribe from topic %s during shutdown: %s",
225
- topic,
226
- result,
227
- )
228
-
229
- if self._supervisor_task and not self._supervisor_task.done():
230
- self._supervisor_task.cancel()
231
- try:
232
- await self._supervisor_task
233
- except asyncio.CancelledError:
234
- self.logger.debug("Supervisor task cancelled during shutdown")
235
-
227
+ await self._unsubscribe_all()
228
+ await self._stop_supervisor()
236
229
  await self._cleanup_connection()
237
230
  self._connected_event.clear()
238
231
  self._transition_state(ClientState.CLOSED)
239
232
 
233
+ async def _unsubscribe_all(self) -> None:
234
+ topics = list(self._topic_subscriptions)
235
+ results = await asyncio.gather(
236
+ *(
237
+ self.unsubscribe_from_topic(topic, _allow_disconnected=True)
238
+ for topic in topics
239
+ ),
240
+ return_exceptions=True,
241
+ )
242
+ for topic, result in zip(topics, results, strict=True):
243
+ if isinstance(result, Exception):
244
+ self.logger.warning(
245
+ "Failed to unsubscribe from topic %s during shutdown: %s",
246
+ topic,
247
+ result,
248
+ )
249
+
250
+ async def _stop_supervisor(self) -> None:
251
+ # Cleared, so run_forever() after the stop reports the client isn't running.
252
+ supervisor, self._supervisor_task = self._supervisor_task, None
253
+ if supervisor is not None and not supervisor.done():
254
+ await cancel_and_wait(supervisor)
255
+
240
256
  def _transition_state(self, new_state: ClientState) -> None:
241
257
  if self._state == new_state:
242
258
  return