messagex-client 0.1.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.
- messagex_client-0.1.0/LICENSE +21 -0
- messagex_client-0.1.0/PKG-INFO +664 -0
- messagex_client-0.1.0/README.md +639 -0
- messagex_client-0.1.0/pyproject.toml +38 -0
- messagex_client-0.1.0/setup.cfg +4 -0
- messagex_client-0.1.0/src/messagex/__init__.py +32 -0
- messagex_client-0.1.0/src/messagex/client.py +531 -0
- messagex_client-0.1.0/src/messagex/errors.py +25 -0
- messagex_client-0.1.0/src/messagex/events.py +58 -0
- messagex_client-0.1.0/src/messagex/frame.py +57 -0
- messagex_client-0.1.0/src/messagex/py.typed +0 -0
- messagex_client-0.1.0/src/messagex/types.py +82 -0
- messagex_client-0.1.0/src/messagex_client.egg-info/PKG-INFO +664 -0
- messagex_client-0.1.0/src/messagex_client.egg-info/SOURCES.txt +16 -0
- messagex_client-0.1.0/src/messagex_client.egg-info/dependency_links.txt +1 -0
- messagex_client-0.1.0/src/messagex_client.egg-info/top_level.txt +1 -0
- messagex_client-0.1.0/tests/test_client.py +256 -0
- messagex_client-0.1.0/tests/test_frame.py +47 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MessageX contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: messagex-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python (asyncio) client for the MessageX message broker
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/sameeryadav05/MessageX/tree/main/packages/python-sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/sameeryadav05/MessageX
|
|
8
|
+
Project-URL: Issues, https://github.com/sameeryadav05/MessageX/issues
|
|
9
|
+
Keywords: messagex,message-broker,queue,pubsub,messaging,tcp,asyncio,event-driven
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Networking
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# MessageX Python SDK
|
|
27
|
+
|
|
28
|
+
The official **asyncio** client for the [MessageX](https://github.com/sameeryadav05/MessageX) self-hosted message broker.
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/messagex-client/)
|
|
31
|
+
[](https://www.python.org/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
Zero runtime dependencies · fully typed (`py.typed`) · same protocol (v1) as the [JavaScript SDK](https://www.npmjs.com/package/messagex-client).
|
|
35
|
+
|
|
36
|
+
## Contents
|
|
37
|
+
|
|
38
|
+
- [Installation](#installation)
|
|
39
|
+
- [Quick start](#quick-start-5-minutes)
|
|
40
|
+
- [Core concepts](#core-concepts)
|
|
41
|
+
- [Connecting](#connecting)
|
|
42
|
+
- [Declaring queues and exchanges](#declaring-queues-and-exchanges)
|
|
43
|
+
- [Publishing messages](#publishing-messages)
|
|
44
|
+
- [Consuming messages](#consuming-messages)
|
|
45
|
+
- [Routing with exchange types](#routing-with-exchange-types)
|
|
46
|
+
- [Retries and dead-letter queues](#retries-and-dead-letter-queues)
|
|
47
|
+
- [Events](#events)
|
|
48
|
+
- [Error handling](#error-handling)
|
|
49
|
+
- [Recipes](#recipes) (auto-reconnect, graceful shutdown, FastAPI, logging)
|
|
50
|
+
- [Troubleshooting](#troubleshooting)
|
|
51
|
+
- [API reference](#api-reference)
|
|
52
|
+
- [Security and limitations](#security-and-limitations)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install messagex-client
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The package name on PyPI is `messagex-client`; you import it as `messagex`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from messagex import MessageX
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Requires Python 3.9+ and a running MessageX broker (the standard Docker deployment listens on `localhost:5678`).
|
|
69
|
+
|
|
70
|
+
> **This is an asyncio library.** Every network call is a coroutine, so you `await` it inside an `async def` function and start your program with `asyncio.run(...)`. In Jupyter/IPython you can use top-level `await` directly.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Quick start (5 minutes)
|
|
75
|
+
|
|
76
|
+
Set your broker credentials once:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
export MESSAGEX_USER="your-username"
|
|
80
|
+
export MESSAGEX_PASSWORD="your-password"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 1. Consumer: `consumer.py`
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import asyncio
|
|
87
|
+
import os
|
|
88
|
+
|
|
89
|
+
from messagex import MessageX
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def handle(message):
|
|
93
|
+
print("Received:", message.body)
|
|
94
|
+
await message.ack() # tell the broker we're done with it
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def main():
|
|
98
|
+
async with MessageX(
|
|
99
|
+
host="127.0.0.1",
|
|
100
|
+
username=os.environ["MESSAGEX_USER"],
|
|
101
|
+
password=os.environ["MESSAGEX_PASSWORD"],
|
|
102
|
+
) as client:
|
|
103
|
+
# Set up the topology (exchange -> binding -> queue)
|
|
104
|
+
await client.exchange.declare("orders.exchange", "direct", durable=True)
|
|
105
|
+
await client.queue.declare("orders", durable=True)
|
|
106
|
+
await client.exchange.bind("orders.exchange", "orders", "orders.created")
|
|
107
|
+
|
|
108
|
+
await client.consume("orders", handle)
|
|
109
|
+
print("Waiting for messages. Press Ctrl+C to stop.")
|
|
110
|
+
await asyncio.Event().wait() # run forever
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
try:
|
|
115
|
+
asyncio.run(main())
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
pass
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 2. Producer: `producer.py`
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import asyncio
|
|
124
|
+
import os
|
|
125
|
+
|
|
126
|
+
from messagex import MessageX
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def main():
|
|
130
|
+
async with MessageX(
|
|
131
|
+
host="127.0.0.1",
|
|
132
|
+
username=os.environ["MESSAGEX_USER"],
|
|
133
|
+
password=os.environ["MESSAGEX_PASSWORD"],
|
|
134
|
+
) as client:
|
|
135
|
+
result = await client.publish(
|
|
136
|
+
"orders.exchange",
|
|
137
|
+
{"orderId": "ORD-1001", "amount": 2500, "status": "created"},
|
|
138
|
+
routing_key="orders.created",
|
|
139
|
+
headers={"source": "checkout-service"},
|
|
140
|
+
)
|
|
141
|
+
print("Routed to", result.routed_queues, "queue(s)")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
asyncio.run(main())
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Run `python consumer.py` in one terminal, then `python producer.py` in another. The consumer prints the order.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Core concepts
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
Producer ──publish──▶ Exchange ──binding (routing key)──▶ Queue ──deliver──▶ Consumer
|
|
155
|
+
"orders.exchange" "orders.created" "orders" ack / nack
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| Concept | What it is |
|
|
159
|
+
|---|---|
|
|
160
|
+
| **Exchange** | Receives published messages and decides which queue(s) get them. Has a type: `direct`, `fanout`, `topic` or `headers`. |
|
|
161
|
+
| **Queue** | Holds messages until a consumer processes them. |
|
|
162
|
+
| **Binding** | A rule linking an exchange to a queue, usually with a *routing key*. |
|
|
163
|
+
| **Routing key** | A label on each published message that bindings match against. |
|
|
164
|
+
| **Consumer** | Your code, subscribed to a queue. Each delivered message must be **acked** (done) or **nacked** (failed). |
|
|
165
|
+
|
|
166
|
+
A message is **published to an exchange** (never directly to a queue). If no binding matches, `result.routed_queues` is `0`.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Connecting
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
client = MessageX(
|
|
174
|
+
host="127.0.0.1", # required
|
|
175
|
+
username="user", # required
|
|
176
|
+
password="secret", # required
|
|
177
|
+
port=5678, # default 5678
|
|
178
|
+
client_name="billing-service",
|
|
179
|
+
connect_timeout=10.0, # seconds
|
|
180
|
+
request_timeout=15.0, # seconds
|
|
181
|
+
max_frame_size=1_048_576, # bytes
|
|
182
|
+
)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Option A: context manager (recommended)
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
async with MessageX(host=..., username=..., password=...) as client:
|
|
189
|
+
... # connected and authenticated here
|
|
190
|
+
# closed automatically, even if an exception was raised
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Option B: manual lifecycle
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
client = MessageX(host=..., username=..., password=...)
|
|
197
|
+
await client.connect()
|
|
198
|
+
try:
|
|
199
|
+
...
|
|
200
|
+
finally:
|
|
201
|
+
await client.close()
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`connect()` is safe to call more than once; concurrent calls share a single TCP connection. Check state with `client.is_connected` and `client.is_authenticated`.
|
|
205
|
+
|
|
206
|
+
### Reading config from the environment
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
import os
|
|
210
|
+
from messagex import MessageX
|
|
211
|
+
|
|
212
|
+
def make_client() -> MessageX:
|
|
213
|
+
return MessageX(
|
|
214
|
+
host=os.environ.get("MESSAGEX_HOST", "127.0.0.1"),
|
|
215
|
+
port=int(os.environ.get("MESSAGEX_PORT", "5678")),
|
|
216
|
+
username=os.environ["MESSAGEX_USER"],
|
|
217
|
+
password=os.environ["MESSAGEX_PASSWORD"],
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Never hard-code credentials in source control.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Declaring queues and exchanges
|
|
226
|
+
|
|
227
|
+
Declare what you need when your service starts.
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
await client.queue.declare(
|
|
231
|
+
"orders",
|
|
232
|
+
durable=True, # survive broker restarts
|
|
233
|
+
exclusive=False,
|
|
234
|
+
auto_delete=False,
|
|
235
|
+
max_delivery_count=5, # see "Retries and dead-letter queues"
|
|
236
|
+
dead_letter_queue="orders.dead",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
await client.exchange.declare("orders.exchange", "topic", durable=True)
|
|
240
|
+
|
|
241
|
+
await client.exchange.bind("orders.exchange", "orders", "orders.*")
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Options you leave out (or pass as `None`) are not sent, so the broker applies its own defaults.
|
|
245
|
+
|
|
246
|
+
To remove things:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
await client.exchange.unbind("orders.exchange", "orders", "orders.*")
|
|
250
|
+
await client.queue.delete("orders")
|
|
251
|
+
await client.exchange.delete("orders.exchange")
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
These calls return the broker's response as a plain `dict` using the broker's key names, for example:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
info = await client.queue.declare("orders", durable=True)
|
|
258
|
+
print(info["queue"]["name"])
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Publishing messages
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
result = await client.publish(
|
|
267
|
+
"orders.exchange", # exchange name
|
|
268
|
+
{"orderId": "ORD-1001", "amount": 2500}, # message: any JSON-serializable value
|
|
269
|
+
routing_key="orders.created", # optional, default ""
|
|
270
|
+
headers={"source": "checkout-service"}, # optional metadata
|
|
271
|
+
)
|
|
272
|
+
print(result.exchange_name, result.routed_queues)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- The message can be a `dict`, `list`, `str`, number, `bool` or `None`.
|
|
276
|
+
- Non-JSON values (for example `datetime`, `Decimal`, `set`, custom objects) raise `TypeError`. Convert them first, e.g. `order["created"] = created.isoformat()`.
|
|
277
|
+
- A message larger than `max_frame_size` raises `ValueError`.
|
|
278
|
+
- `routed_queues == 0` means the broker accepted the message but no queue was bound to receive it.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Consuming messages
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
async def handle(message):
|
|
286
|
+
...
|
|
287
|
+
|
|
288
|
+
subscription = await client.consume("orders", handle)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`consume()` returns a `ConsumerSubscription` with `consumer_tag`, `queue_name` and `await cancel()`.
|
|
292
|
+
|
|
293
|
+
### The message object
|
|
294
|
+
|
|
295
|
+
| Attribute / method | Description |
|
|
296
|
+
|---|---|
|
|
297
|
+
| `message.body` | The decoded message (what the producer published) |
|
|
298
|
+
| `message.headers` | Headers dict |
|
|
299
|
+
| `message.routing_key` | Routing key it was published with |
|
|
300
|
+
| `message.message_id` | Broker message id |
|
|
301
|
+
| `message.delivery_count` | How many times this message has been delivered |
|
|
302
|
+
| `message.created_at` | Creation timestamp (string) |
|
|
303
|
+
| `message.queue_name`, `message.consumer_tag`, `message.delivery_tag` | Delivery identifiers |
|
|
304
|
+
| `await message.ack()` | Success: the message is done |
|
|
305
|
+
| `await message.nack(requeue=True)` | Failure: put it back (`True`) or reject it (`False`) |
|
|
306
|
+
|
|
307
|
+
### Always ack or nack
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
async def handle(message):
|
|
311
|
+
try:
|
|
312
|
+
await process_order(message.body)
|
|
313
|
+
await message.ack()
|
|
314
|
+
except TemporaryError:
|
|
315
|
+
await message.nack(requeue=True) # try again later
|
|
316
|
+
except Exception:
|
|
317
|
+
await message.nack(requeue=False) # give up on this message
|
|
318
|
+
raise
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The SDK **never** acks or nacks for you. The broker currently uses a prefetch of 1 per consumer connection, so an unsettled message can hold up the ones behind it.
|
|
322
|
+
|
|
323
|
+
### Sync or async handlers
|
|
324
|
+
|
|
325
|
+
Handlers can be `async def` (recommended, since you can `await message.ack()` inside) or a plain function that returns an awaitable:
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
def handle(message):
|
|
329
|
+
return message.ack() # returning the awaitable is enough
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
A plain synchronous handler runs on the event loop, so keep it fast. Do not call blocking code (`time.sleep`, `requests`, heavy CPU work) inside a handler. Use `await asyncio.to_thread(blocking_fn, ...)` for that.
|
|
333
|
+
|
|
334
|
+
If your handler raises, the SDK emits a [`consumer_error`](#events) event and carries on with the next message.
|
|
335
|
+
|
|
336
|
+
### Stopping a consumer
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
await subscription.cancel() # stop receiving, keep the connection open
|
|
340
|
+
await client.close() # or close everything
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Routing with exchange types
|
|
346
|
+
|
|
347
|
+
| Type | Delivers a message to... |
|
|
348
|
+
|---|---|
|
|
349
|
+
| `direct` | queues bound with a routing key equal to the message's routing key |
|
|
350
|
+
| `fanout` | every bound queue (routing key not used for matching) |
|
|
351
|
+
| `topic` | queues whose binding pattern matches the routing key, e.g. `orders.*` |
|
|
352
|
+
| `headers` | queues whose binding `arguments` match the message headers |
|
|
353
|
+
|
|
354
|
+
### Direct: one key, one destination
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
await client.exchange.declare("tasks", "direct")
|
|
358
|
+
await client.queue.declare("emails")
|
|
359
|
+
await client.exchange.bind("tasks", "emails", "send.email")
|
|
360
|
+
await client.publish("tasks", {"to": "a@b.com"}, routing_key="send.email")
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Fanout: broadcast to everyone
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
await client.exchange.declare("events", "fanout")
|
|
367
|
+
for name in ("audit", "analytics", "notifications"):
|
|
368
|
+
await client.queue.declare(name)
|
|
369
|
+
await client.exchange.bind("events", name) # no routing key needed
|
|
370
|
+
await client.publish("events", {"type": "user.signup"}) # copied to all three queues
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Topic: pattern matching
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
await client.exchange.declare("orders.exchange", "topic")
|
|
377
|
+
await client.queue.declare("all-orders")
|
|
378
|
+
await client.exchange.bind("orders.exchange", "all-orders", "orders.*")
|
|
379
|
+
await client.publish("orders.exchange", {"id": 1}, routing_key="orders.created")
|
|
380
|
+
await client.publish("orders.exchange", {"id": 2}, routing_key="orders.cancelled")
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Headers: match on metadata
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
await client.exchange.declare("reports", "headers")
|
|
387
|
+
await client.exchange.bind("reports", "pdf-queue", arguments={"format": "pdf"})
|
|
388
|
+
await client.publish("reports", {"id": 7}, headers={"format": "pdf"})
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The exact matching rules for each type are defined by the broker. See the [MessageX documentation](https://github.com/sameeryadav05/MessageX#readme) for details, especially for `headers` bindings.
|
|
392
|
+
|
|
393
|
+
### Work queue: scale out by running more consumers
|
|
394
|
+
|
|
395
|
+
Run the same consumer script in several processes against the same queue to spread the work.
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Retries and dead-letter queues
|
|
400
|
+
|
|
401
|
+
Queues accept `max_delivery_count` and `dead_letter_queue`, so messages that keep failing can be routed to a separate queue instead of looping forever:
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
await client.queue.declare("orders.dead", durable=True)
|
|
405
|
+
await client.queue.declare(
|
|
406
|
+
"orders",
|
|
407
|
+
durable=True,
|
|
408
|
+
max_delivery_count=5,
|
|
409
|
+
dead_letter_queue="orders.dead",
|
|
410
|
+
)
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Use `message.delivery_count` to see which attempt you are on:
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
async def handle(message):
|
|
417
|
+
try:
|
|
418
|
+
await process(message.body)
|
|
419
|
+
await message.ack()
|
|
420
|
+
except Exception:
|
|
421
|
+
print(f"attempt {message.delivery_count} failed")
|
|
422
|
+
await message.nack(requeue=True)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Events
|
|
428
|
+
|
|
429
|
+
The client emits events you can subscribe to with `on`, `once` and `off`. Listeners can be sync or async.
|
|
430
|
+
|
|
431
|
+
| Event | Arguments | When |
|
|
432
|
+
|---|---|---|
|
|
433
|
+
| `connect` | none | Connected and authenticated |
|
|
434
|
+
| `disconnect` | none | The connection dropped unexpectedly (not emitted by your own `close()`) |
|
|
435
|
+
| `close` | none | `close()` finished |
|
|
436
|
+
| `message` | `DeliveredMessage` | Any message delivered (in addition to your handler) |
|
|
437
|
+
| `event` | `dict` | Other broker events |
|
|
438
|
+
| `consumer_error` | `exception, message` | Your handler raised |
|
|
439
|
+
| `warning` | `Exception` | Non-fatal problem (e.g. delivery for an unknown consumer) |
|
|
440
|
+
| `error` | `Exception` | Connection or protocol error |
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
client.on("disconnect", lambda: print("connection lost!"))
|
|
444
|
+
client.on("consumer_error", lambda exc, msg: print(f"handler failed on {msg.message_id}: {exc!r}"))
|
|
445
|
+
client.on("warning", lambda err: print("warning:", err))
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
If there is no `error` listener, errors are emitted as `warning`. If there is no `warning` listener either, they are written to the `messagex` logger.
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Error handling
|
|
453
|
+
|
|
454
|
+
All SDK errors inherit from `MessageXError`:
|
|
455
|
+
|
|
456
|
+
| Exception | Meaning |
|
|
457
|
+
|---|---|
|
|
458
|
+
| `MessageXConnectionError` | Can't connect, not connected/authenticated, or the connection was lost |
|
|
459
|
+
| `MessageXTimeoutError` | Connect or a command exceeded its timeout |
|
|
460
|
+
| `MessageXBrokerError` | The broker rejected the command; has `.code` and `.message` |
|
|
461
|
+
|
|
462
|
+
```python
|
|
463
|
+
from messagex import (
|
|
464
|
+
MessageXBrokerError, MessageXConnectionError, MessageXTimeoutError,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
await client.queue.declare("orders", durable=True)
|
|
469
|
+
except MessageXBrokerError as err:
|
|
470
|
+
print("Broker said no:", err.code, err)
|
|
471
|
+
except MessageXTimeoutError:
|
|
472
|
+
print("Broker too slow")
|
|
473
|
+
except MessageXConnectionError as err:
|
|
474
|
+
print("Connection problem:", err)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Argument mistakes raise standard Python errors: `TypeError` (missing credentials, non-JSON message) or `ValueError` (message too large).
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## Recipes
|
|
482
|
+
|
|
483
|
+
### Auto-reconnect
|
|
484
|
+
|
|
485
|
+
The SDK does **not** reconnect or restore subscriptions by itself. After a disconnect you must connect and `consume()` again. A simple, robust pattern is to build a fresh client in a loop:
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
import asyncio
|
|
489
|
+
from messagex import MessageX, MessageXConnectionError, MessageXTimeoutError
|
|
490
|
+
|
|
491
|
+
async def run_consumer(stop: asyncio.Event):
|
|
492
|
+
delay = 1.0
|
|
493
|
+
while not stop.is_set():
|
|
494
|
+
client = make_client() # see "Reading config from the environment"
|
|
495
|
+
lost = asyncio.Event()
|
|
496
|
+
client.on("disconnect", lambda: lost.set())
|
|
497
|
+
try:
|
|
498
|
+
await client.connect()
|
|
499
|
+
await client.consume("orders", handle)
|
|
500
|
+
delay = 1.0 # healthy again: reset backoff
|
|
501
|
+
|
|
502
|
+
waiter = asyncio.ensure_future(lost.wait())
|
|
503
|
+
stopper = asyncio.ensure_future(stop.wait())
|
|
504
|
+
await asyncio.wait({waiter, stopper}, return_when=asyncio.FIRST_COMPLETED)
|
|
505
|
+
waiter.cancel()
|
|
506
|
+
stopper.cancel()
|
|
507
|
+
except (MessageXConnectionError, MessageXTimeoutError) as exc:
|
|
508
|
+
print("connection failed:", exc)
|
|
509
|
+
finally:
|
|
510
|
+
await client.close()
|
|
511
|
+
|
|
512
|
+
if not stop.is_set():
|
|
513
|
+
await asyncio.sleep(delay)
|
|
514
|
+
delay = min(delay * 2, 30.0) # exponential backoff, max 30 s
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### Graceful shutdown (Ctrl+C / SIGTERM)
|
|
518
|
+
|
|
519
|
+
```python
|
|
520
|
+
import asyncio, signal
|
|
521
|
+
|
|
522
|
+
async def main():
|
|
523
|
+
stop = asyncio.Event()
|
|
524
|
+
loop = asyncio.get_running_loop()
|
|
525
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
526
|
+
loop.add_signal_handler(sig, stop.set) # not available on Windows
|
|
527
|
+
|
|
528
|
+
async with make_client() as client:
|
|
529
|
+
subscription = await client.consume("orders", handle)
|
|
530
|
+
await stop.wait()
|
|
531
|
+
await subscription.cancel() # stop taking new work, then exit
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Publishing from a FastAPI app
|
|
535
|
+
|
|
536
|
+
Create one client at startup and reuse it for every request:
|
|
537
|
+
|
|
538
|
+
```python
|
|
539
|
+
from contextlib import asynccontextmanager
|
|
540
|
+
from fastapi import FastAPI, Request
|
|
541
|
+
|
|
542
|
+
@asynccontextmanager
|
|
543
|
+
async def lifespan(app: FastAPI):
|
|
544
|
+
client = make_client()
|
|
545
|
+
await client.connect()
|
|
546
|
+
app.state.mx = client
|
|
547
|
+
yield
|
|
548
|
+
await client.close()
|
|
549
|
+
|
|
550
|
+
app = FastAPI(lifespan=lifespan)
|
|
551
|
+
|
|
552
|
+
@app.post("/orders")
|
|
553
|
+
async def create_order(order: dict, request: Request):
|
|
554
|
+
result = await request.app.state.mx.publish(
|
|
555
|
+
"orders.exchange", order, routing_key="orders.created"
|
|
556
|
+
)
|
|
557
|
+
return {"queued": result.routed_queues > 0}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
### Publishing from synchronous code
|
|
561
|
+
|
|
562
|
+
If your code is not async, run a short-lived call with `asyncio.run`:
|
|
563
|
+
|
|
564
|
+
```python
|
|
565
|
+
import asyncio
|
|
566
|
+
|
|
567
|
+
def publish_once(payload: dict) -> None:
|
|
568
|
+
async def _go():
|
|
569
|
+
async with make_client() as client:
|
|
570
|
+
await client.publish("orders.exchange", payload, routing_key="orders.created")
|
|
571
|
+
asyncio.run(_go())
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
This opens a new connection per call, so use it for scripts and occasional jobs, not high-volume publishing. Don't call `asyncio.run` from inside an already-running event loop.
|
|
575
|
+
|
|
576
|
+
### Logging
|
|
577
|
+
|
|
578
|
+
```python
|
|
579
|
+
import logging
|
|
580
|
+
logging.basicConfig(level=logging.INFO)
|
|
581
|
+
logging.getLogger("messagex").setLevel(logging.DEBUG)
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## Troubleshooting
|
|
587
|
+
|
|
588
|
+
| Symptom | Likely cause and fix |
|
|
589
|
+
|---|---|
|
|
590
|
+
| `MessageXConnectionError: Could not connect to MessageX at host:port` | Broker not running, wrong host/port, or firewall. Check `docker ps` and the port (default `5678`). |
|
|
591
|
+
| `MessageXTimeoutError` while connecting | Host unreachable, or something other than MessageX is listening on that port. Raise `connect_timeout` if the network is slow. |
|
|
592
|
+
| `MessageXBrokerError` right after connect | Wrong username/password. Inspect `err.code`. |
|
|
593
|
+
| `MessageXConnectionError: ... Call connect() first` | You used the client before `connect()` or after `close()`. |
|
|
594
|
+
| `MessageXConnectionError: ... not authenticated` | A command was sent while `connect()` was still in progress. `await client.connect()` first. |
|
|
595
|
+
| `publish` works but `routed_queues` is `0` | No binding matches the routing key. Check exchange type, queue binding and routing key spelling. |
|
|
596
|
+
| Consumer gets one message and then stops | You didn't `ack()`/`nack()` it. Settle every message. |
|
|
597
|
+
| `TypeError: MessageX protocol message must be JSON serializable` | Convert `datetime`/`Decimal`/custom objects to plain JSON types before publishing. |
|
|
598
|
+
| `RuntimeWarning: coroutine ... was never awaited` | You forgot `await` on a client call (`await client.publish(...)`). |
|
|
599
|
+
| Handler seems to freeze all processing | A blocking call inside a handler is blocking the event loop. Use `await asyncio.to_thread(...)`. |
|
|
600
|
+
| `asyncio.run() cannot be called from a running event loop` | You're in Jupyter/an async framework. Use `await main()` instead. |
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
## API reference
|
|
605
|
+
|
|
606
|
+
### `MessageX(host, username, password, port=5678, *, client_name, connect_timeout, request_timeout, max_frame_size)`
|
|
607
|
+
|
|
608
|
+
| Argument | Type | Default | Description |
|
|
609
|
+
|---|---|---:|---|
|
|
610
|
+
| `host` | `str` | required | Broker hostname or IP |
|
|
611
|
+
| `username` | `str` | required | Broker username |
|
|
612
|
+
| `password` | `str` | required | Broker password |
|
|
613
|
+
| `port` | `int` | `5678` | Broker TCP port |
|
|
614
|
+
| `client_name` | `str` | `"messagex-python-client"` | Client identifier |
|
|
615
|
+
| `connect_timeout` | `float` | `10.0` | TCP connect timeout (seconds) |
|
|
616
|
+
| `request_timeout` | `float` | `15.0` | Per-command timeout (seconds) |
|
|
617
|
+
| `max_frame_size` | `int` | `1048576` | Max frame body size (bytes) |
|
|
618
|
+
|
|
619
|
+
### Methods
|
|
620
|
+
|
|
621
|
+
| Method | Returns |
|
|
622
|
+
|---|---|
|
|
623
|
+
| `await connect()` | `None` |
|
|
624
|
+
| `await close()` | `None` |
|
|
625
|
+
| `await publish(exchange_name, message, routing_key="", headers=None)` | `PublishResult(exchange_name, routed_queues)` |
|
|
626
|
+
| `await consume(queue_name, on_message)` | `ConsumerSubscription` |
|
|
627
|
+
| `await queue.declare(name, *, durable, exclusive, auto_delete, max_delivery_count, dead_letter_queue)` | `dict` |
|
|
628
|
+
| `await queue.delete(queue_name)` | `dict` |
|
|
629
|
+
| `await exchange.declare(name, type, *, durable)` | `dict` |
|
|
630
|
+
| `await exchange.delete(exchange_name)` | `dict` |
|
|
631
|
+
| `await exchange.bind(exchange_name, queue_name, routing_key="", arguments=None)` | `dict` |
|
|
632
|
+
| `await exchange.unbind(exchange_name, queue_name, routing_key="", arguments=None)` | `dict` |
|
|
633
|
+
| `on(event, fn)` / `once(event, fn)` / `off(event, fn)` | the client |
|
|
634
|
+
|
|
635
|
+
### Properties
|
|
636
|
+
|
|
637
|
+
`is_connected`, `is_authenticated`
|
|
638
|
+
|
|
639
|
+
### Exports
|
|
640
|
+
|
|
641
|
+
`MessageX`, `MessageXError`, `MessageXConnectionError`, `MessageXTimeoutError`, `MessageXBrokerError`, `DeliveredMessage`, `ConsumerSubscription`, `PublishResult`, `ExchangeType`, `MessageHandler`
|
|
642
|
+
|
|
643
|
+
---
|
|
644
|
+
|
|
645
|
+
## Security and limitations
|
|
646
|
+
|
|
647
|
+
- The broker's TCP protocol currently has **no TLS**. Don't send credentials or messages over untrusted networks or expose port `5678` to the public internet. Use a private network, VPN or SSH tunnel, and firewall rules.
|
|
648
|
+
- No automatic reconnect or subscription recovery (see the [recipe](#auto-reconnect)).
|
|
649
|
+
- Not provided: TLS transport, transactions, batch publishing, management HTTP API, a blocking (non-asyncio) API.
|
|
650
|
+
- Implements protocol v1: CONNECT, AUTH, QUEUE_DECLARE, QUEUE_DELETE, EXCHANGE_DECLARE, EXCHANGE_DELETE, BIND, UNBIND, PUBLISH, CONSUME, ACK, NACK, CANCEL.
|
|
651
|
+
|
|
652
|
+
## Development
|
|
653
|
+
|
|
654
|
+
```bash
|
|
655
|
+
git clone https://github.com/sameeryadav05/MessageX && cd MessageX/packages/python-sdk
|
|
656
|
+
pip install -e .
|
|
657
|
+
PYTHONPATH=src:tests python -m unittest discover -s tests -v
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
End-to-end example against a running broker: [`examples/real_broker_test.py`](examples/real_broker_test.py).
|
|
661
|
+
|
|
662
|
+
## License
|
|
663
|
+
|
|
664
|
+
MIT
|