messagex-client 0.1.0__py3-none-any.whl
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/__init__.py +32 -0
- messagex/client.py +531 -0
- messagex/errors.py +25 -0
- messagex/events.py +58 -0
- messagex/frame.py +57 -0
- messagex/py.typed +0 -0
- messagex/types.py +82 -0
- messagex_client-0.1.0.dist-info/METADATA +664 -0
- messagex_client-0.1.0.dist-info/RECORD +12 -0
- messagex_client-0.1.0.dist-info/WHEEL +5 -0
- messagex_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- messagex_client-0.1.0.dist-info/top_level.txt +1 -0
messagex/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Official Python (asyncio) client for the MessageX message broker."""
|
|
2
|
+
|
|
3
|
+
from .client import MessageX
|
|
4
|
+
from .errors import (
|
|
5
|
+
MessageXBrokerError,
|
|
6
|
+
MessageXConnectionError,
|
|
7
|
+
MessageXError,
|
|
8
|
+
MessageXTimeoutError,
|
|
9
|
+
)
|
|
10
|
+
from .types import (
|
|
11
|
+
ConsumerSubscription,
|
|
12
|
+
DeliveredMessage,
|
|
13
|
+
ExchangeType,
|
|
14
|
+
MessageHandler,
|
|
15
|
+
PublishResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"MessageX",
|
|
22
|
+
"MessageXError",
|
|
23
|
+
"MessageXConnectionError",
|
|
24
|
+
"MessageXTimeoutError",
|
|
25
|
+
"MessageXBrokerError",
|
|
26
|
+
"DeliveredMessage",
|
|
27
|
+
"ConsumerSubscription",
|
|
28
|
+
"PublishResult",
|
|
29
|
+
"ExchangeType",
|
|
30
|
+
"MessageHandler",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
messagex/client.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, Dict, Mapping, Optional, Set
|
|
8
|
+
|
|
9
|
+
from .errors import (
|
|
10
|
+
MessageXBrokerError,
|
|
11
|
+
MessageXConnectionError,
|
|
12
|
+
MessageXTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
from .events import EventEmitter, logger
|
|
15
|
+
from .frame import FrameDecoder, encode_frame
|
|
16
|
+
from .types import (
|
|
17
|
+
BindingInfo,
|
|
18
|
+
ConsumerSubscription,
|
|
19
|
+
DeliveredMessage,
|
|
20
|
+
ExchangeInfo,
|
|
21
|
+
ExchangeType,
|
|
22
|
+
MessageHandler,
|
|
23
|
+
PublishResult,
|
|
24
|
+
QueueInfo,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
PROTOCOL_VERSION = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _compact(values: Mapping[str, Any]) -> Dict[str, Any]:
|
|
31
|
+
"""Drop ``None`` values so the broker applies its own defaults."""
|
|
32
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Consumer:
|
|
36
|
+
__slots__ = ("queue_name", "on_message")
|
|
37
|
+
|
|
38
|
+
def __init__(self, queue_name: str, on_message: MessageHandler) -> None:
|
|
39
|
+
self.queue_name = queue_name
|
|
40
|
+
self.on_message = on_message
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _QueueApi:
|
|
44
|
+
def __init__(self, client: "MessageX") -> None:
|
|
45
|
+
self._client = client
|
|
46
|
+
|
|
47
|
+
async def declare(
|
|
48
|
+
self,
|
|
49
|
+
name: str,
|
|
50
|
+
*,
|
|
51
|
+
durable: Optional[bool] = None,
|
|
52
|
+
exclusive: Optional[bool] = None,
|
|
53
|
+
auto_delete: Optional[bool] = None,
|
|
54
|
+
max_delivery_count: Optional[int] = None,
|
|
55
|
+
dead_letter_queue: Optional[str] = None,
|
|
56
|
+
) -> Dict[str, QueueInfo]:
|
|
57
|
+
"""Declare a queue. Returns the broker payload: ``{"queue": {...}}``."""
|
|
58
|
+
return await self._client._request(
|
|
59
|
+
"QUEUE_DECLARE",
|
|
60
|
+
_compact(
|
|
61
|
+
{
|
|
62
|
+
"name": name,
|
|
63
|
+
"durable": durable,
|
|
64
|
+
"exclusive": exclusive,
|
|
65
|
+
"autoDelete": auto_delete,
|
|
66
|
+
"maxDeliveryCount": max_delivery_count,
|
|
67
|
+
"deadLetterQueue": dead_letter_queue,
|
|
68
|
+
}
|
|
69
|
+
),
|
|
70
|
+
) # type: ignore[return-value]
|
|
71
|
+
|
|
72
|
+
async def delete(self, queue_name: str) -> Dict[str, Any]:
|
|
73
|
+
return await self._client._request("QUEUE_DELETE", {"queueName": queue_name})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _ExchangeApi:
|
|
77
|
+
def __init__(self, client: "MessageX") -> None:
|
|
78
|
+
self._client = client
|
|
79
|
+
|
|
80
|
+
async def declare(
|
|
81
|
+
self, name: str, type: ExchangeType, *, durable: Optional[bool] = None
|
|
82
|
+
) -> Dict[str, ExchangeInfo]:
|
|
83
|
+
"""Declare an exchange. Returns the broker payload: ``{"exchange": {...}}``."""
|
|
84
|
+
return await self._client._request(
|
|
85
|
+
"EXCHANGE_DECLARE", _compact({"name": name, "type": type, "durable": durable})
|
|
86
|
+
) # type: ignore[return-value]
|
|
87
|
+
|
|
88
|
+
async def delete(self, exchange_name: str) -> Dict[str, Any]:
|
|
89
|
+
return await self._client._request("EXCHANGE_DELETE", {"exchangeName": exchange_name})
|
|
90
|
+
|
|
91
|
+
async def bind(
|
|
92
|
+
self,
|
|
93
|
+
exchange_name: str,
|
|
94
|
+
queue_name: str,
|
|
95
|
+
routing_key: str = "",
|
|
96
|
+
arguments: Optional[Mapping[str, Any]] = None,
|
|
97
|
+
) -> Dict[str, BindingInfo]:
|
|
98
|
+
"""Bind a queue to an exchange. Returns ``{"binding": {...}}``."""
|
|
99
|
+
return await self._client._request(
|
|
100
|
+
"BIND",
|
|
101
|
+
{
|
|
102
|
+
"exchangeName": exchange_name,
|
|
103
|
+
"queueName": queue_name,
|
|
104
|
+
"routingKey": routing_key,
|
|
105
|
+
"arguments": dict(arguments or {}),
|
|
106
|
+
},
|
|
107
|
+
) # type: ignore[return-value]
|
|
108
|
+
|
|
109
|
+
async def unbind(
|
|
110
|
+
self,
|
|
111
|
+
exchange_name: str,
|
|
112
|
+
queue_name: str,
|
|
113
|
+
routing_key: str = "",
|
|
114
|
+
arguments: Optional[Mapping[str, Any]] = None,
|
|
115
|
+
) -> Dict[str, Any]:
|
|
116
|
+
return await self._client._request(
|
|
117
|
+
"UNBIND",
|
|
118
|
+
{
|
|
119
|
+
"exchangeName": exchange_name,
|
|
120
|
+
"queueName": queue_name,
|
|
121
|
+
"routingKey": routing_key,
|
|
122
|
+
"arguments": dict(arguments or {}),
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MessageX(EventEmitter):
|
|
128
|
+
"""Asyncio client for the MessageX broker.
|
|
129
|
+
|
|
130
|
+
Events (``client.on(name, listener)``): ``connect``, ``disconnect``, ``close``,
|
|
131
|
+
``message``, ``event``, ``warning``, ``consumer_error`` and ``error``.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
host: str,
|
|
137
|
+
username: str,
|
|
138
|
+
password: str,
|
|
139
|
+
port: int = 5678,
|
|
140
|
+
*,
|
|
141
|
+
client_name: str = "messagex-python-client",
|
|
142
|
+
connect_timeout: float = 10.0,
|
|
143
|
+
request_timeout: float = 15.0,
|
|
144
|
+
max_frame_size: int = 1024 * 1024,
|
|
145
|
+
) -> None:
|
|
146
|
+
super().__init__()
|
|
147
|
+
if not host or not username or not password:
|
|
148
|
+
raise TypeError("host, username and password are required")
|
|
149
|
+
|
|
150
|
+
self.host = host
|
|
151
|
+
self.port = port
|
|
152
|
+
self._username = username
|
|
153
|
+
self._password = password
|
|
154
|
+
self.client_name = client_name
|
|
155
|
+
self.connect_timeout = connect_timeout
|
|
156
|
+
self.request_timeout = request_timeout
|
|
157
|
+
self.max_frame_size = max_frame_size
|
|
158
|
+
|
|
159
|
+
self._reader: Optional[asyncio.StreamReader] = None
|
|
160
|
+
self._writer: Optional[asyncio.StreamWriter] = None
|
|
161
|
+
self._reader_task: Optional["asyncio.Task[None]"] = None
|
|
162
|
+
self._connect_task: Optional["asyncio.Task[None]"] = None
|
|
163
|
+
self._decoder = FrameDecoder(max_frame_size)
|
|
164
|
+
self._pending: Dict[str, "asyncio.Future[Dict[str, Any]]"] = {}
|
|
165
|
+
self._consumers: Dict[str, _Consumer] = {}
|
|
166
|
+
self._pending_consumers_by_queue: Dict[str, _Consumer] = {}
|
|
167
|
+
self._callback_tasks: Set["asyncio.Task[None]"] = set()
|
|
168
|
+
self._closing = False
|
|
169
|
+
self._connected = False
|
|
170
|
+
self._authenticated = False
|
|
171
|
+
|
|
172
|
+
self.queue = _QueueApi(self)
|
|
173
|
+
self.exchange = _ExchangeApi(self)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------ state
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def is_connected(self) -> bool:
|
|
179
|
+
return self._connected and self._writer is not None and not self._writer.is_closing()
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def is_authenticated(self) -> bool:
|
|
183
|
+
return self._authenticated
|
|
184
|
+
|
|
185
|
+
async def __aenter__(self) -> "MessageX":
|
|
186
|
+
await self.connect()
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
190
|
+
await self.close()
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------- connection
|
|
193
|
+
|
|
194
|
+
async def connect(self) -> None:
|
|
195
|
+
"""Open the TCP connection and perform the CONNECT/AUTH handshake."""
|
|
196
|
+
if self.is_connected and self._authenticated:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
task = self._connect_task
|
|
200
|
+
if task is None:
|
|
201
|
+
task = asyncio.ensure_future(self._establish_connection())
|
|
202
|
+
self._connect_task = task
|
|
203
|
+
|
|
204
|
+
def _clear(done: "asyncio.Task[None]") -> None:
|
|
205
|
+
if self._connect_task is done:
|
|
206
|
+
self._connect_task = None
|
|
207
|
+
if not done.cancelled():
|
|
208
|
+
done.exception() # mark retrieved
|
|
209
|
+
|
|
210
|
+
task.add_done_callback(_clear)
|
|
211
|
+
|
|
212
|
+
await asyncio.shield(task)
|
|
213
|
+
|
|
214
|
+
async def _establish_connection(self) -> None:
|
|
215
|
+
if self.is_connected:
|
|
216
|
+
raise MessageXConnectionError("Socket is connected but authentication is incomplete")
|
|
217
|
+
|
|
218
|
+
self._closing = False
|
|
219
|
+
self._decoder.reset()
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
reader, writer = await asyncio.wait_for(
|
|
223
|
+
asyncio.open_connection(self.host, self.port), self.connect_timeout
|
|
224
|
+
)
|
|
225
|
+
except asyncio.TimeoutError:
|
|
226
|
+
raise MessageXTimeoutError(
|
|
227
|
+
f"Timed out connecting to {self.host}:{self.port}"
|
|
228
|
+
) from None
|
|
229
|
+
except OSError as exc:
|
|
230
|
+
raise MessageXConnectionError(
|
|
231
|
+
f"Could not connect to MessageX at {self.host}:{self.port}: {exc}"
|
|
232
|
+
) from exc
|
|
233
|
+
|
|
234
|
+
self._reader, self._writer = reader, writer
|
|
235
|
+
self._connected = True
|
|
236
|
+
self._reader_task = asyncio.ensure_future(self._read_loop(reader, writer))
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
await self._request("CONNECT", {"clientName": self.client_name})
|
|
240
|
+
await self._request("AUTH", {"username": self._username, "password": self._password})
|
|
241
|
+
self._authenticated = True
|
|
242
|
+
self.emit("connect")
|
|
243
|
+
except BaseException:
|
|
244
|
+
self._destroy_connection()
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
async def close(self) -> None:
|
|
248
|
+
"""Close the socket, clear consumers and reject pending requests."""
|
|
249
|
+
self._closing = True
|
|
250
|
+
self._authenticated = False
|
|
251
|
+
self._connected = False
|
|
252
|
+
self._consumers.clear()
|
|
253
|
+
self._pending_consumers_by_queue.clear()
|
|
254
|
+
|
|
255
|
+
writer = self._writer
|
|
256
|
+
task = self._reader_task
|
|
257
|
+
if writer is not None:
|
|
258
|
+
writer.close()
|
|
259
|
+
try:
|
|
260
|
+
await asyncio.wait_for(writer.wait_closed(), 1.0)
|
|
261
|
+
except (Exception, asyncio.TimeoutError):
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
if task is not None and task is not asyncio.current_task() and not task.done():
|
|
265
|
+
task.cancel()
|
|
266
|
+
await asyncio.wait([task])
|
|
267
|
+
|
|
268
|
+
self._reject_pending(MessageXConnectionError("MessageX client was closed"))
|
|
269
|
+
self._writer = None
|
|
270
|
+
self._reader = None
|
|
271
|
+
self._reader_task = None
|
|
272
|
+
self.emit("close")
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------- operations
|
|
275
|
+
|
|
276
|
+
async def publish(
|
|
277
|
+
self,
|
|
278
|
+
exchange_name: str,
|
|
279
|
+
message: Any,
|
|
280
|
+
routing_key: str = "",
|
|
281
|
+
headers: Optional[Mapping[str, Any]] = None,
|
|
282
|
+
) -> PublishResult:
|
|
283
|
+
"""Publish a JSON-serializable ``message`` to an exchange."""
|
|
284
|
+
payload = await self._request(
|
|
285
|
+
"PUBLISH",
|
|
286
|
+
{
|
|
287
|
+
"exchangeName": exchange_name,
|
|
288
|
+
"routingKey": routing_key,
|
|
289
|
+
"message": message,
|
|
290
|
+
"headers": dict(headers or {}),
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
return PublishResult(
|
|
294
|
+
exchange_name=str(payload.get("exchangeName", exchange_name)),
|
|
295
|
+
routed_queues=int(payload.get("routedQueues", 0)),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
async def consume(self, queue_name: str, on_message: MessageHandler) -> ConsumerSubscription:
|
|
299
|
+
"""Start consuming ``queue_name``. ``on_message`` may be sync or async."""
|
|
300
|
+
if not callable(on_message):
|
|
301
|
+
raise TypeError("consume requires an on_message callback")
|
|
302
|
+
|
|
303
|
+
consumer = _Consumer(queue_name, on_message)
|
|
304
|
+
|
|
305
|
+
# The broker may emit MESSAGE_DELIVERED before replying to CONSUME, so the
|
|
306
|
+
# callback is registered by queue name first to avoid losing that delivery.
|
|
307
|
+
self._pending_consumers_by_queue[queue_name] = consumer
|
|
308
|
+
try:
|
|
309
|
+
payload = await self._request("CONSUME", {"queueName": queue_name})
|
|
310
|
+
finally:
|
|
311
|
+
self._pending_consumers_by_queue.pop(queue_name, None)
|
|
312
|
+
|
|
313
|
+
consumer_tag = str(payload.get("consumerTag") or "")
|
|
314
|
+
if not consumer_tag:
|
|
315
|
+
raise MessageXConnectionError("Broker did not return a consumerTag")
|
|
316
|
+
|
|
317
|
+
self._consumers[consumer_tag] = consumer
|
|
318
|
+
cancelled = False
|
|
319
|
+
|
|
320
|
+
async def cancel() -> None:
|
|
321
|
+
nonlocal cancelled
|
|
322
|
+
if cancelled:
|
|
323
|
+
return
|
|
324
|
+
await self._request("CANCEL", {"consumerTag": consumer_tag})
|
|
325
|
+
cancelled = True
|
|
326
|
+
self._consumers.pop(consumer_tag, None)
|
|
327
|
+
|
|
328
|
+
return ConsumerSubscription(consumer_tag, queue_name, cancel)
|
|
329
|
+
|
|
330
|
+
# --------------------------------------------------------------- protocol
|
|
331
|
+
|
|
332
|
+
async def _request(self, command: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
333
|
+
writer = self._writer
|
|
334
|
+
if writer is None or writer.is_closing() or not self._connected:
|
|
335
|
+
raise MessageXConnectionError("Not connected to MessageX. Call connect() first.")
|
|
336
|
+
if command not in ("CONNECT", "AUTH") and not self._authenticated:
|
|
337
|
+
raise MessageXConnectionError(
|
|
338
|
+
"MessageX client is not authenticated. Await connect() before sending commands."
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
request_id = str(uuid.uuid4())
|
|
342
|
+
frame = encode_frame(
|
|
343
|
+
{
|
|
344
|
+
"version": PROTOCOL_VERSION,
|
|
345
|
+
"type": "COMMAND",
|
|
346
|
+
"command": command,
|
|
347
|
+
"requestId": request_id,
|
|
348
|
+
"payload": payload,
|
|
349
|
+
},
|
|
350
|
+
self.max_frame_size,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
future: "asyncio.Future[Dict[str, Any]]" = asyncio.get_running_loop().create_future()
|
|
354
|
+
self._pending[request_id] = future
|
|
355
|
+
try:
|
|
356
|
+
try:
|
|
357
|
+
writer.write(frame)
|
|
358
|
+
await writer.drain()
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
raise MessageXConnectionError(f"Failed to send {command}: {exc}") from exc
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
return await asyncio.wait_for(future, self.request_timeout)
|
|
364
|
+
except asyncio.TimeoutError:
|
|
365
|
+
raise MessageXTimeoutError(
|
|
366
|
+
f"MessageX command {command} timed out after {self.request_timeout}s"
|
|
367
|
+
) from None
|
|
368
|
+
finally:
|
|
369
|
+
self._pending.pop(request_id, None)
|
|
370
|
+
|
|
371
|
+
async def _read_loop(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
372
|
+
try:
|
|
373
|
+
while True:
|
|
374
|
+
chunk = await reader.read(65536)
|
|
375
|
+
if not chunk:
|
|
376
|
+
break
|
|
377
|
+
try:
|
|
378
|
+
self._handle_data(chunk)
|
|
379
|
+
except Exception as exc:
|
|
380
|
+
self._emit_error(exc)
|
|
381
|
+
self._destroy_connection()
|
|
382
|
+
return
|
|
383
|
+
except asyncio.CancelledError:
|
|
384
|
+
raise
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
if not self._closing:
|
|
387
|
+
self._emit_error(MessageXConnectionError(str(exc)))
|
|
388
|
+
finally:
|
|
389
|
+
self._handle_close(writer)
|
|
390
|
+
|
|
391
|
+
def _handle_data(self, chunk: bytes) -> None:
|
|
392
|
+
for frame in self._decoder.push(chunk):
|
|
393
|
+
parsed = json.loads(frame.decode("utf-8"))
|
|
394
|
+
if parsed.get("version") != PROTOCOL_VERSION:
|
|
395
|
+
raise ValueError(f"Unsupported broker protocol version: {parsed.get('version')}")
|
|
396
|
+
kind = parsed.get("type")
|
|
397
|
+
if kind == "RESPONSE":
|
|
398
|
+
self._handle_response(parsed)
|
|
399
|
+
elif kind == "EVENT":
|
|
400
|
+
self._handle_event(parsed)
|
|
401
|
+
|
|
402
|
+
def _handle_response(self, response: Dict[str, Any]) -> None:
|
|
403
|
+
request_id = response.get("requestId")
|
|
404
|
+
future = self._pending.pop(request_id, None)
|
|
405
|
+
if future is None:
|
|
406
|
+
self._emit_warning(
|
|
407
|
+
MessageXConnectionError(f"Received response for unknown requestId {request_id}")
|
|
408
|
+
)
|
|
409
|
+
return
|
|
410
|
+
if future.done():
|
|
411
|
+
return
|
|
412
|
+
if not response.get("success"):
|
|
413
|
+
future.set_exception(
|
|
414
|
+
MessageXBrokerError(
|
|
415
|
+
response.get("error")
|
|
416
|
+
or {"code": "ERR_UNKNOWN", "message": "Broker command failed"}
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
future.set_result(response.get("payload") or {})
|
|
421
|
+
|
|
422
|
+
def _handle_event(self, event: Dict[str, Any]) -> None:
|
|
423
|
+
if event.get("event") != "MESSAGE_DELIVERED":
|
|
424
|
+
self.emit("event", event)
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
data = event.get("payload") or {}
|
|
428
|
+
consumer_tag = str(data.get("consumerTag") or "")
|
|
429
|
+
consumer = self._consumers.get(consumer_tag) or self._pending_consumers_by_queue.get(
|
|
430
|
+
str(data.get("queueName") or "")
|
|
431
|
+
)
|
|
432
|
+
if consumer is None:
|
|
433
|
+
self._emit_warning(
|
|
434
|
+
MessageXConnectionError(f"Message delivered for unknown consumer {consumer_tag}")
|
|
435
|
+
)
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
delivery_tag = str(data.get("deliveryTag") or "")
|
|
439
|
+
|
|
440
|
+
async def ack() -> None:
|
|
441
|
+
await self._request("ACK", {"deliveryTag": delivery_tag})
|
|
442
|
+
|
|
443
|
+
async def nack(requeue: bool) -> None:
|
|
444
|
+
await self._request("NACK", {"deliveryTag": delivery_tag, "requeue": requeue})
|
|
445
|
+
|
|
446
|
+
delivered = DeliveredMessage(
|
|
447
|
+
consumer_tag=consumer_tag,
|
|
448
|
+
queue_name=str(data.get("queueName") or consumer.queue_name),
|
|
449
|
+
message_id=str(data.get("messageId") or ""),
|
|
450
|
+
delivery_tag=delivery_tag,
|
|
451
|
+
body=data.get("body"),
|
|
452
|
+
headers=dict(data.get("headers") or {}),
|
|
453
|
+
routing_key=str(data.get("routingKey") or ""),
|
|
454
|
+
delivery_count=int(data.get("deliveryCount") or 0),
|
|
455
|
+
created_at=str(data.get("createdAt") or ""),
|
|
456
|
+
_ack=ack,
|
|
457
|
+
_nack=nack,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
self._dispatch(consumer, delivered)
|
|
461
|
+
self.emit("message", delivered)
|
|
462
|
+
|
|
463
|
+
def _dispatch(self, consumer: _Consumer, delivered: DeliveredMessage) -> None:
|
|
464
|
+
try:
|
|
465
|
+
result = consumer.on_message(delivered)
|
|
466
|
+
except Exception as exc:
|
|
467
|
+
self.emit("consumer_error", exc, delivered)
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
if inspect.isawaitable(result):
|
|
471
|
+
task = asyncio.ensure_future(self._await_callback(result, delivered))
|
|
472
|
+
self._callback_tasks.add(task)
|
|
473
|
+
task.add_done_callback(self._callback_tasks.discard)
|
|
474
|
+
|
|
475
|
+
async def _await_callback(self, awaitable: Any, delivered: DeliveredMessage) -> None:
|
|
476
|
+
try:
|
|
477
|
+
await awaitable
|
|
478
|
+
except Exception as exc:
|
|
479
|
+
self.emit("consumer_error", exc, delivered)
|
|
480
|
+
|
|
481
|
+
# ---------------------------------------------------------------- cleanup
|
|
482
|
+
|
|
483
|
+
def _handle_close(self, writer: asyncio.StreamWriter) -> None:
|
|
484
|
+
if self._writer is not writer:
|
|
485
|
+
return # a newer connection owns the state now
|
|
486
|
+
was_connected = self._connected
|
|
487
|
+
self._connected = False
|
|
488
|
+
self._authenticated = False
|
|
489
|
+
if not writer.is_closing():
|
|
490
|
+
writer.close()
|
|
491
|
+
self._writer = None
|
|
492
|
+
self._reader = None
|
|
493
|
+
self._decoder.reset()
|
|
494
|
+
self._consumers.clear()
|
|
495
|
+
self._pending_consumers_by_queue.clear()
|
|
496
|
+
self._reject_pending(MessageXConnectionError("Connection to MessageX was closed"))
|
|
497
|
+
if not self._closing and was_connected:
|
|
498
|
+
self.emit("disconnect")
|
|
499
|
+
|
|
500
|
+
def _destroy_connection(self) -> None:
|
|
501
|
+
self._connected = False
|
|
502
|
+
self._authenticated = False
|
|
503
|
+
self._consumers.clear()
|
|
504
|
+
self._pending_consumers_by_queue.clear()
|
|
505
|
+
self._reject_pending(MessageXConnectionError("MessageX connection was terminated"))
|
|
506
|
+
writer, task = self._writer, self._reader_task
|
|
507
|
+
self._writer = None
|
|
508
|
+
self._reader = None
|
|
509
|
+
if writer is not None:
|
|
510
|
+
writer.close()
|
|
511
|
+
if task is not None and task is not asyncio.current_task() and not task.done():
|
|
512
|
+
task.cancel()
|
|
513
|
+
self._reader_task = None
|
|
514
|
+
|
|
515
|
+
def _reject_pending(self, error: Exception) -> None:
|
|
516
|
+
for request_id, future in list(self._pending.items()):
|
|
517
|
+
if not future.done():
|
|
518
|
+
future.set_exception(error)
|
|
519
|
+
self._pending.pop(request_id, None)
|
|
520
|
+
|
|
521
|
+
def _emit_error(self, error: Exception) -> None:
|
|
522
|
+
if self.listener_count("error") > 0:
|
|
523
|
+
self.emit("error", error)
|
|
524
|
+
else:
|
|
525
|
+
self._emit_warning(error)
|
|
526
|
+
|
|
527
|
+
def _emit_warning(self, error: Exception) -> None:
|
|
528
|
+
if self.listener_count("warning") > 0:
|
|
529
|
+
self.emit("warning", error)
|
|
530
|
+
else:
|
|
531
|
+
logger.warning("%s", error)
|
messagex/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MessageXError(Exception):
|
|
7
|
+
"""Base class for all SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MessageXConnectionError(MessageXError):
|
|
11
|
+
"""Socket and connection lifecycle errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MessageXTimeoutError(MessageXError):
|
|
15
|
+
"""A connection attempt or broker command timed out."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MessageXBrokerError(MessageXError):
|
|
19
|
+
"""The broker rejected a command. See :attr:`code`."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, error: Mapping[str, Any]) -> None:
|
|
22
|
+
message = str(error.get("message", "Broker command failed"))
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.code: str = str(error.get("code", "ERR_UNKNOWN"))
|
|
25
|
+
self.message: str = message
|
messagex/events.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable, Dict, List
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("messagex")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventEmitter:
|
|
12
|
+
"""Tiny Node-style emitter. Listeners may be sync functions or coroutine functions."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._listeners: Dict[str, List[Callable[..., Any]]] = {}
|
|
16
|
+
self._background: set = set()
|
|
17
|
+
|
|
18
|
+
def on(self, event: str, listener: Callable[..., Any]) -> "EventEmitter":
|
|
19
|
+
self._listeners.setdefault(event, []).append(listener)
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def once(self, event: str, listener: Callable[..., Any]) -> "EventEmitter":
|
|
23
|
+
def wrapper(*args: Any) -> Any:
|
|
24
|
+
self.off(event, wrapper)
|
|
25
|
+
return listener(*args)
|
|
26
|
+
|
|
27
|
+
wrapper.__wrapped__ = listener # type: ignore[attr-defined]
|
|
28
|
+
return self.on(event, wrapper)
|
|
29
|
+
|
|
30
|
+
def off(self, event: str, listener: Callable[..., Any]) -> "EventEmitter":
|
|
31
|
+
items = self._listeners.get(event, [])
|
|
32
|
+
for item in list(items):
|
|
33
|
+
if item is listener or getattr(item, "__wrapped__", None) is listener:
|
|
34
|
+
items.remove(item)
|
|
35
|
+
break
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def listener_count(self, event: str) -> int:
|
|
39
|
+
return len(self._listeners.get(event, []))
|
|
40
|
+
|
|
41
|
+
def emit(self, event: str, *args: Any) -> bool:
|
|
42
|
+
listeners = list(self._listeners.get(event, []))
|
|
43
|
+
for listener in listeners:
|
|
44
|
+
try:
|
|
45
|
+
result = listener(*args)
|
|
46
|
+
if inspect.isawaitable(result):
|
|
47
|
+
task = asyncio.ensure_future(self._run(event, result))
|
|
48
|
+
self._background.add(task)
|
|
49
|
+
task.add_done_callback(self._background.discard)
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.exception("Unhandled exception in %r listener", event)
|
|
52
|
+
return bool(listeners)
|
|
53
|
+
|
|
54
|
+
async def _run(self, event: str, awaitable: Any) -> None:
|
|
55
|
+
try:
|
|
56
|
+
await awaitable
|
|
57
|
+
except Exception:
|
|
58
|
+
logger.exception("Unhandled exception in %r listener", event)
|