rabbitkit 0.9.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.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
rabbitkit/rpc.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""RPCClient — request/response over RabbitMQ.
|
|
2
|
+
|
|
3
|
+
Uses direct reply-to (amq.rabbitmq.reply-to) for lowest latency.
|
|
4
|
+
One client instance reuses one reply queue.
|
|
5
|
+
correlation_id: UUID per request, matched on response.
|
|
6
|
+
Timeout: configurable per call, raises RPCTimeoutError.
|
|
7
|
+
|
|
8
|
+
Error behavior (deliberate design choice):
|
|
9
|
+
- If handler raises and ExceptionMiddleware returns fallback with publish=True →
|
|
10
|
+
fallback publishes to result_publisher (if @publisher set), NOT to reply_to.
|
|
11
|
+
- By default, RPC caller receives RPCTimeoutError on handler failure.
|
|
12
|
+
- This is intentional: exception context is not an RPC response.
|
|
13
|
+
|
|
14
|
+
Fast-failure pattern (RECOMMENDED for production RPC handlers):
|
|
15
|
+
- Handlers should catch exceptions and return standardized error envelopes
|
|
16
|
+
explicitly, so callers fail fast instead of waiting for timeout.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from concurrent.futures import Future
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from rabbitkit.core.message import RabbitMessage
|
|
30
|
+
from rabbitkit.core.types import DIRECT_REPLY_TO_QUEUE, MessageEnvelope
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RPCTimeoutError(TimeoutError):
|
|
36
|
+
"""Raised when an RPC call times out waiting for a response."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, correlation_id: str, timeout: float) -> None:
|
|
39
|
+
self.correlation_id = correlation_id
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
super().__init__(f"RPC call timed out after {timeout}s (correlation_id={correlation_id})")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RPCClientClosed(RuntimeError):
|
|
45
|
+
"""Raised by :meth:`RPCClient.call` when the client has been closed.
|
|
46
|
+
|
|
47
|
+
``close()`` resolves all outstanding waiters with this error instead of
|
|
48
|
+
leaving ``result=None`` (which previously caused ``AttributeError``).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ReplyTooLargeError(Exception):
|
|
53
|
+
"""Raised when an RPC reply body exceeds ``max_reply_bytes`` (L-8)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, correlation_id: str, size: int, limit: int) -> None:
|
|
56
|
+
self.correlation_id = correlation_id
|
|
57
|
+
self.size = size
|
|
58
|
+
self.limit = limit
|
|
59
|
+
super().__init__(
|
|
60
|
+
f"RPC reply (correlation_id={correlation_id}) body size {size} exceeds max_reply_bytes limit {limit}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _ReplyConnection:
|
|
65
|
+
"""Minimal pika ``BlockingConnection``-like interface used by RPCClient.
|
|
66
|
+
|
|
67
|
+
``RPCClient`` owns a *dedicated* reply connection so it can pump the I/O
|
|
68
|
+
loop itself while waiting for a reply. This avoids deadlocking the broker's
|
|
69
|
+
transport I/O thread when sync RPC is called from a handler (especially
|
|
70
|
+
under ``worker_count=1``).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def process_data_events(self, time_limit: float = 0.0) -> None:
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Shared reply-routing infrastructure ───────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Sink:
|
|
81
|
+
"""Abstract sink wrapping a Future for RPC reply resolution.
|
|
82
|
+
|
|
83
|
+
Both ``concurrent.futures.Future`` (sync) and ``asyncio.Future`` (async)
|
|
84
|
+
satisfy the ``set_result`` / ``set_exception`` / ``done`` / ``cancel``
|
|
85
|
+
contract, but the two Future types are not assignment-compatible under
|
|
86
|
+
``mypy --strict``. This base unifies them so :class:`_ReplyRouter` can hold
|
|
87
|
+
a single ``dict[str, _Sink]``.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
__slots__ = ()
|
|
91
|
+
|
|
92
|
+
def set_result(self, msg: RabbitMessage) -> None:
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
def set_exception(self, exc: BaseException) -> None:
|
|
96
|
+
raise NotImplementedError
|
|
97
|
+
|
|
98
|
+
def done(self) -> bool:
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
def cancel(self) -> bool:
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _FutureSink(_Sink):
|
|
106
|
+
"""Sink wrapping ``concurrent.futures.Future`` for the sync RPC client."""
|
|
107
|
+
|
|
108
|
+
__slots__ = ("_fut",)
|
|
109
|
+
|
|
110
|
+
def __init__(self, fut: Future[RabbitMessage]) -> None:
|
|
111
|
+
self._fut = fut
|
|
112
|
+
|
|
113
|
+
def set_result(self, msg: RabbitMessage) -> None:
|
|
114
|
+
self._fut.set_result(msg)
|
|
115
|
+
|
|
116
|
+
def set_exception(self, exc: BaseException) -> None:
|
|
117
|
+
self._fut.set_exception(exc)
|
|
118
|
+
|
|
119
|
+
def done(self) -> bool:
|
|
120
|
+
return self._fut.done()
|
|
121
|
+
|
|
122
|
+
def cancel(self) -> bool:
|
|
123
|
+
return self._fut.cancel()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _AsyncFutureSink(_Sink):
|
|
127
|
+
"""Sink wrapping ``asyncio.Future`` for the async RPC client."""
|
|
128
|
+
|
|
129
|
+
__slots__ = ("_fut",)
|
|
130
|
+
|
|
131
|
+
def __init__(self, fut: asyncio.Future[RabbitMessage]) -> None:
|
|
132
|
+
self._fut = fut
|
|
133
|
+
|
|
134
|
+
def set_result(self, msg: RabbitMessage) -> None:
|
|
135
|
+
self._fut.set_result(msg)
|
|
136
|
+
|
|
137
|
+
def set_exception(self, exc: BaseException) -> None:
|
|
138
|
+
self._fut.set_exception(exc)
|
|
139
|
+
|
|
140
|
+
def done(self) -> bool:
|
|
141
|
+
return self._fut.done()
|
|
142
|
+
|
|
143
|
+
def cancel(self) -> bool:
|
|
144
|
+
return self._fut.cancel()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _ReplyRouter:
|
|
148
|
+
"""Shared reply router for sync and async RPC clients.
|
|
149
|
+
|
|
150
|
+
Holds the pending-call registry (``correlation_id → _Sink``), the
|
|
151
|
+
reply-body size cap (``max_reply_bytes``) and the pending cap
|
|
152
|
+
(``max_pending``). The :meth:`resolve` method implements the reply
|
|
153
|
+
callback body — correlation matching, size check, and result/exception
|
|
154
|
+
resolution — in one place; the sync and async clients only differ in
|
|
155
|
+
*how* they lock around it (``threading.Lock`` vs ``asyncio.Lock``).
|
|
156
|
+
|
|
157
|
+
The caller of :meth:`register`, :meth:`pop`, :meth:`resolve`,
|
|
158
|
+
:meth:`close_all`, and :meth:`cancel_all` must hold whatever lock guards
|
|
159
|
+
``_pending``; this class does no locking of its own.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
__slots__ = ("_pending", "max_pending", "max_reply_bytes")
|
|
163
|
+
|
|
164
|
+
def __init__(self, *, max_reply_bytes: int | None, max_pending: int) -> None:
|
|
165
|
+
self.max_reply_bytes = max_reply_bytes
|
|
166
|
+
self.max_pending = max_pending
|
|
167
|
+
self._pending: dict[str, _Sink] = {}
|
|
168
|
+
|
|
169
|
+
def is_full(self) -> bool:
|
|
170
|
+
return len(self._pending) >= self.max_pending
|
|
171
|
+
|
|
172
|
+
def register(self, cid: str, sink: _Sink) -> None:
|
|
173
|
+
self._pending[cid] = sink
|
|
174
|
+
|
|
175
|
+
def pop(self, cid: str) -> _Sink | None:
|
|
176
|
+
return self._pending.pop(cid, None)
|
|
177
|
+
|
|
178
|
+
def pending_count(self) -> int:
|
|
179
|
+
return len(self._pending)
|
|
180
|
+
|
|
181
|
+
def resolve(self, message: RabbitMessage) -> None:
|
|
182
|
+
"""Shared reply body: correlation match → size check → resolve sink.
|
|
183
|
+
|
|
184
|
+
Must be called holding whatever lock guards ``_pending``.
|
|
185
|
+
"""
|
|
186
|
+
cid = message.correlation_id
|
|
187
|
+
if not cid:
|
|
188
|
+
logger.warning("RPC reply without correlation_id, discarding")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
sink = self._pending.get(cid)
|
|
192
|
+
if sink is None:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"Late or unknown RPC reply (correlation_id=%s), discarding",
|
|
195
|
+
cid,
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if sink.done():
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# L-8/L7: this check runs AFTER the transport has already received
|
|
203
|
+
# and fully buffered the reply body (message.body is a complete
|
|
204
|
+
# bytes object by the time resolve() is called) -- it does NOT
|
|
205
|
+
# prevent a huge/zip-bomb reply from being materialized in memory.
|
|
206
|
+
# It only stops the CALLER from receiving/holding onto an
|
|
207
|
+
# oversized result: surfaces as a typed ReplyTooLargeError instead
|
|
208
|
+
# of silently handing back a giant buffer. Enforcing at the AMQP
|
|
209
|
+
# frame level (rejecting mid-receive, before the body is fully
|
|
210
|
+
# assembled) would require intercepting delivery inside
|
|
211
|
+
# pika/aio-pika itself and is not done here.
|
|
212
|
+
if self.max_reply_bytes is not None and len(message.body) > self.max_reply_bytes:
|
|
213
|
+
sink.set_exception(ReplyTooLargeError(cid, len(message.body), self.max_reply_bytes))
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
sink.set_result(message)
|
|
217
|
+
|
|
218
|
+
def close_all(self, exc: BaseException) -> None:
|
|
219
|
+
"""Resolve every pending sink with *exc* and clear the registry.
|
|
220
|
+
|
|
221
|
+
Used by the sync client's ``close()`` so waiters raise cleanly.
|
|
222
|
+
Must be called holding whatever lock guards ``_pending``.
|
|
223
|
+
"""
|
|
224
|
+
for sink in self._pending.values():
|
|
225
|
+
if not sink.done():
|
|
226
|
+
sink.set_exception(exc)
|
|
227
|
+
self._pending.clear()
|
|
228
|
+
|
|
229
|
+
def cancel_all(self) -> None:
|
|
230
|
+
"""Cancel every pending sink and clear the registry.
|
|
231
|
+
|
|
232
|
+
Used by the async client's ``close()`` to cancel outstanding futures.
|
|
233
|
+
Must be called holding whatever lock guards ``_pending``.
|
|
234
|
+
"""
|
|
235
|
+
for sink in self._pending.values():
|
|
236
|
+
if not sink.done():
|
|
237
|
+
sink.cancel()
|
|
238
|
+
self._pending.clear()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── Sync RPC client ───────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class RPCClient:
|
|
245
|
+
"""Synchronous RPC client over RabbitMQ.
|
|
246
|
+
|
|
247
|
+
Usage::
|
|
248
|
+
|
|
249
|
+
client = RPCClient(transport)
|
|
250
|
+
response = client.call("rpc.orders", b'{"id": 1}', timeout=5.0)
|
|
251
|
+
print(response.body)
|
|
252
|
+
client.close()
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
transport: Any,
|
|
258
|
+
*,
|
|
259
|
+
serializer: Any | None = None,
|
|
260
|
+
max_pending: int = 100,
|
|
261
|
+
reply_connection: Any | None = None,
|
|
262
|
+
close_reply_connection: bool = False,
|
|
263
|
+
max_reply_bytes: int | None = None,
|
|
264
|
+
) -> None:
|
|
265
|
+
self._transport = transport
|
|
266
|
+
self._serializer = serializer
|
|
267
|
+
self._reply_queue = DIRECT_REPLY_TO_QUEUE
|
|
268
|
+
|
|
269
|
+
# Shared reply router holds max_reply_bytes / max_pending / _pending.
|
|
270
|
+
self._router = _ReplyRouter(
|
|
271
|
+
max_reply_bytes=max_reply_bytes,
|
|
272
|
+
max_pending=max_pending,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Dedicated reply connection. When provided, ``call()`` pumps it via
|
|
276
|
+
# ``process_data_events`` while waiting so the broker's I/O thread is
|
|
277
|
+
# never blocked. When ``None`` (e.g. in tests using a transport mock),
|
|
278
|
+
# ``call()`` falls back to blocking on the future — which works when
|
|
279
|
+
# replies are delivered out-of-band (the existing test harness).
|
|
280
|
+
#
|
|
281
|
+
# Ownership: by default the caller owns *reply_connection* and is
|
|
282
|
+
# responsible for closing it (it may be shared). Set
|
|
283
|
+
# ``close_reply_connection=True`` to have close() close it too.
|
|
284
|
+
self._connection: Any | None = reply_connection
|
|
285
|
+
self._close_reply_connection = bool(close_reply_connection)
|
|
286
|
+
|
|
287
|
+
self._lock = threading.Lock()
|
|
288
|
+
self._consuming = False
|
|
289
|
+
self._consumer_tag: str | None = None
|
|
290
|
+
self._starting = False
|
|
291
|
+
# L-5: guards call()/_ensure_consuming() after close().
|
|
292
|
+
self._closed = False
|
|
293
|
+
|
|
294
|
+
def call(
|
|
295
|
+
self,
|
|
296
|
+
routing_key: str,
|
|
297
|
+
body: bytes,
|
|
298
|
+
*,
|
|
299
|
+
timeout: float = 5.0,
|
|
300
|
+
exchange: str = "",
|
|
301
|
+
headers: dict[str, Any] | None = None,
|
|
302
|
+
) -> RabbitMessage:
|
|
303
|
+
"""Send an RPC request and wait for a response.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
routing_key: The routing key (queue name) to send the request to.
|
|
307
|
+
body: The request body.
|
|
308
|
+
timeout: Maximum time to wait for a response (seconds).
|
|
309
|
+
exchange: The exchange to publish to (default: "" for direct).
|
|
310
|
+
headers: Optional headers to include in the request.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
RabbitMessage: The response message.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
RPCTimeoutError: If the response is not received within the timeout.
|
|
317
|
+
RPCClientClosed: If the client was closed while waiting.
|
|
318
|
+
RuntimeError: If max_pending calls is exceeded.
|
|
319
|
+
|
|
320
|
+
Note:
|
|
321
|
+
Sync RPC requires its own dedicated reply connection (passed via
|
|
322
|
+
``reply_connection``) so the I/O loop can be pumped while waiting.
|
|
323
|
+
Calling sync RPC from inside a ``worker_count=1`` sync handler with
|
|
324
|
+
a shared broker connection would otherwise deadlock.
|
|
325
|
+
"""
|
|
326
|
+
# L-5: refuse to operate after close() instead of silently re-registering
|
|
327
|
+
# a consumer on a half-torn-down client.
|
|
328
|
+
if self._closed:
|
|
329
|
+
raise RPCClientClosed("RPCClient is closed")
|
|
330
|
+
self._ensure_consuming()
|
|
331
|
+
|
|
332
|
+
correlation_id = str(uuid.uuid4())
|
|
333
|
+
|
|
334
|
+
with self._lock:
|
|
335
|
+
if self._router.is_full():
|
|
336
|
+
raise RuntimeError(
|
|
337
|
+
f"Max pending RPC calls ({self._router.max_pending}) exceeded. "
|
|
338
|
+
"Consider increasing max_pending or reducing call rate."
|
|
339
|
+
)
|
|
340
|
+
fut: Future[RabbitMessage] = Future()
|
|
341
|
+
self._router.register(correlation_id, _FutureSink(fut))
|
|
342
|
+
|
|
343
|
+
# Publish request
|
|
344
|
+
envelope = MessageEnvelope(
|
|
345
|
+
routing_key=routing_key,
|
|
346
|
+
body=body,
|
|
347
|
+
exchange=exchange,
|
|
348
|
+
reply_to=self._reply_queue,
|
|
349
|
+
correlation_id=correlation_id,
|
|
350
|
+
headers=headers or {},
|
|
351
|
+
)
|
|
352
|
+
self._transport.publish(envelope)
|
|
353
|
+
|
|
354
|
+
# Wait for the response. If we own a dedicated reply connection, pump
|
|
355
|
+
# its I/O loop ourselves; otherwise block directly on the future
|
|
356
|
+
# (replies are delivered out-of-band, e.g. by a test harness or another
|
|
357
|
+
# thread).
|
|
358
|
+
# L-4: wrap the pump in try/finally so a `process_data_events` that
|
|
359
|
+
# raises still pops the _pending entry — otherwise it leaks and
|
|
360
|
+
# exhausts `max_pending` over time.
|
|
361
|
+
deadline = time.monotonic() + timeout
|
|
362
|
+
try:
|
|
363
|
+
if self._connection is not None:
|
|
364
|
+
while not fut.done():
|
|
365
|
+
remaining = deadline - time.monotonic()
|
|
366
|
+
if remaining <= 0:
|
|
367
|
+
break
|
|
368
|
+
self._connection.process_data_events(time_limit=min(0.01, remaining))
|
|
369
|
+
else:
|
|
370
|
+
remaining = deadline - time.monotonic()
|
|
371
|
+
if remaining > 0:
|
|
372
|
+
try:
|
|
373
|
+
fut.result(timeout=remaining)
|
|
374
|
+
except TimeoutError:
|
|
375
|
+
pass # handled as timeout below
|
|
376
|
+
finally:
|
|
377
|
+
if not fut.done():
|
|
378
|
+
with self._lock:
|
|
379
|
+
self._router.pop(correlation_id)
|
|
380
|
+
|
|
381
|
+
if not fut.done():
|
|
382
|
+
raise RPCTimeoutError(correlation_id, timeout)
|
|
383
|
+
|
|
384
|
+
with self._lock:
|
|
385
|
+
self._router.pop(correlation_id)
|
|
386
|
+
|
|
387
|
+
# Raises the stored exception (ReplyTooLargeError / RPCClientClosed)
|
|
388
|
+
# or returns the resolved result.
|
|
389
|
+
return fut.result()
|
|
390
|
+
|
|
391
|
+
def close(self) -> None:
|
|
392
|
+
"""Close the RPC client and clean up.
|
|
393
|
+
|
|
394
|
+
Cancels the reply consumer and resolves all pending waiters with an
|
|
395
|
+
``RPCClientClosed`` error so callers fail cleanly instead of hitting
|
|
396
|
+
``AttributeError`` on a ``None`` result.
|
|
397
|
+
|
|
398
|
+
When constructed with ``close_reply_connection=True`` AND a
|
|
399
|
+
``reply_connection``, also closes the dedicated reply connection
|
|
400
|
+
(ownership transferred to the client). Otherwise the caller retains
|
|
401
|
+
ownership of *reply_connection* and must close it themselves.
|
|
402
|
+
"""
|
|
403
|
+
if self._consumer_tag and self._transport:
|
|
404
|
+
try:
|
|
405
|
+
self._transport.cancel_consumer(self._consumer_tag)
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.warning("Failed to cancel RPC reply consumer: %s", e)
|
|
408
|
+
|
|
409
|
+
# Clean up pending calls — set an exception so waiters raise cleanly.
|
|
410
|
+
# L-5: mark the client closed so subsequent call()/_ensure_consuming()
|
|
411
|
+
# refuse to re-register a consumer on the torn-down client.
|
|
412
|
+
with self._lock:
|
|
413
|
+
self._closed = True
|
|
414
|
+
self._router.close_all(RPCClientClosed("RPCClient was closed"))
|
|
415
|
+
|
|
416
|
+
self._consuming = False
|
|
417
|
+
self._consumer_tag = None
|
|
418
|
+
|
|
419
|
+
# Low: optionally close the dedicated reply connection when the client
|
|
420
|
+
# owns it. Default keeps the prior behaviour (caller-owned).
|
|
421
|
+
if self._close_reply_connection and self._connection is not None:
|
|
422
|
+
try:
|
|
423
|
+
self._connection.close()
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logger.warning("Failed to close RPC reply connection: %s", e)
|
|
426
|
+
|
|
427
|
+
def _ensure_consuming(self) -> None:
|
|
428
|
+
"""Ensure the reply consumer is running.
|
|
429
|
+
|
|
430
|
+
The network ``consume()`` call is made *outside* the lock to avoid
|
|
431
|
+
self-deadlock and holding the lock across I/O. A ``_starting`` flag
|
|
432
|
+
guards against concurrent first-callers each registering a consumer.
|
|
433
|
+
"""
|
|
434
|
+
# L-5: refuse to (re-)register a consumer on a closed client.
|
|
435
|
+
if self._closed:
|
|
436
|
+
raise RPCClientClosed("RPCClient is closed")
|
|
437
|
+
with self._lock:
|
|
438
|
+
if self._consuming or self._starting:
|
|
439
|
+
return
|
|
440
|
+
self._starting = True
|
|
441
|
+
|
|
442
|
+
def on_reply(message: RabbitMessage) -> None:
|
|
443
|
+
"""Handle reply messages — delegate correlation match to the router."""
|
|
444
|
+
with self._lock:
|
|
445
|
+
self._router.resolve(message)
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
# amq.rabbitmq.reply-to is a broker pseudo-queue: it rejects any
|
|
449
|
+
# Queue.Declare (declare=False) and requires a no-ack consumer
|
|
450
|
+
# (no_ack=True) — the broker auto-acks each reply on delivery.
|
|
451
|
+
consumer_tag = self._transport.consume(
|
|
452
|
+
queue=self._reply_queue,
|
|
453
|
+
callback=on_reply,
|
|
454
|
+
no_ack=True,
|
|
455
|
+
declare=False,
|
|
456
|
+
)
|
|
457
|
+
except Exception:
|
|
458
|
+
with self._lock:
|
|
459
|
+
self._starting = False
|
|
460
|
+
raise
|
|
461
|
+
|
|
462
|
+
with self._lock:
|
|
463
|
+
self._consumer_tag = consumer_tag
|
|
464
|
+
self._consuming = True
|
|
465
|
+
self._starting = False
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ── Async RPC client ──────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class AsyncRPCClient:
|
|
472
|
+
"""Asynchronous RPC client over RabbitMQ.
|
|
473
|
+
|
|
474
|
+
Usage::
|
|
475
|
+
|
|
476
|
+
client = AsyncRPCClient(transport)
|
|
477
|
+
response = await client.call("rpc.orders", b'{"id": 1}', timeout=5.0)
|
|
478
|
+
print(response.body)
|
|
479
|
+
await client.close()
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
def __init__(
|
|
483
|
+
self,
|
|
484
|
+
transport: Any,
|
|
485
|
+
*,
|
|
486
|
+
serializer: Any | None = None,
|
|
487
|
+
max_pending: int = 100,
|
|
488
|
+
max_reply_bytes: int | None = None,
|
|
489
|
+
) -> None:
|
|
490
|
+
self._transport = transport
|
|
491
|
+
self._serializer = serializer
|
|
492
|
+
self._reply_queue = DIRECT_REPLY_TO_QUEUE
|
|
493
|
+
|
|
494
|
+
# Shared reply router holds max_reply_bytes / max_pending / _pending.
|
|
495
|
+
self._router = _ReplyRouter(
|
|
496
|
+
max_reply_bytes=max_reply_bytes,
|
|
497
|
+
max_pending=max_pending,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
self._lock = asyncio.Lock()
|
|
501
|
+
self._consuming = False
|
|
502
|
+
self._consumer_tag: str | None = None
|
|
503
|
+
# L6: guards call()/_ensure_consuming() after close() — mirrors the
|
|
504
|
+
# sync client's `_closed` guard (see its `_ensure_consuming` for why:
|
|
505
|
+
# without it, a call() after close() would happily re-register a
|
|
506
|
+
# consumer on a torn-down transport).
|
|
507
|
+
self._closed = False
|
|
508
|
+
|
|
509
|
+
async def call(
|
|
510
|
+
self,
|
|
511
|
+
routing_key: str,
|
|
512
|
+
body: bytes,
|
|
513
|
+
*,
|
|
514
|
+
timeout: float = 5.0,
|
|
515
|
+
exchange: str = "",
|
|
516
|
+
headers: dict[str, Any] | None = None,
|
|
517
|
+
) -> RabbitMessage:
|
|
518
|
+
"""Send an RPC request and wait for a response.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
routing_key: The routing key (queue name) to send the request to.
|
|
522
|
+
body: The request body.
|
|
523
|
+
timeout: Maximum time to wait for a response (seconds).
|
|
524
|
+
exchange: The exchange to publish to (default: "" for direct).
|
|
525
|
+
headers: Optional headers to include in the request.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
RabbitMessage: The response message.
|
|
529
|
+
|
|
530
|
+
Raises:
|
|
531
|
+
RPCTimeoutError: If the response is not received within the timeout.
|
|
532
|
+
RPCClientClosed: If the client was closed (or is closed while waiting).
|
|
533
|
+
RuntimeError: If max_pending calls is exceeded.
|
|
534
|
+
"""
|
|
535
|
+
# L6: refuse to operate after close() instead of silently
|
|
536
|
+
# re-registering a consumer on a half-torn-down client.
|
|
537
|
+
if self._closed:
|
|
538
|
+
raise RPCClientClosed("AsyncRPCClient is closed")
|
|
539
|
+
await self._ensure_consuming()
|
|
540
|
+
|
|
541
|
+
correlation_id = str(uuid.uuid4())
|
|
542
|
+
|
|
543
|
+
async with self._lock:
|
|
544
|
+
if self._router.is_full():
|
|
545
|
+
raise RuntimeError(
|
|
546
|
+
f"Max pending RPC calls ({self._router.max_pending}) exceeded. "
|
|
547
|
+
"Consider increasing max_pending or reducing call rate."
|
|
548
|
+
)
|
|
549
|
+
loop = asyncio.get_running_loop()
|
|
550
|
+
future: asyncio.Future[RabbitMessage] = loop.create_future()
|
|
551
|
+
self._router.register(correlation_id, _AsyncFutureSink(future))
|
|
552
|
+
|
|
553
|
+
# Publish request
|
|
554
|
+
envelope = MessageEnvelope(
|
|
555
|
+
routing_key=routing_key,
|
|
556
|
+
body=body,
|
|
557
|
+
exchange=exchange,
|
|
558
|
+
reply_to=self._reply_queue,
|
|
559
|
+
correlation_id=correlation_id,
|
|
560
|
+
headers=headers or {},
|
|
561
|
+
)
|
|
562
|
+
await self._transport.publish(envelope)
|
|
563
|
+
|
|
564
|
+
# Wait for response with timeout. R-timeout: asyncio.timeout (3.11+)
|
|
565
|
+
# replaces asyncio.wait_for to avoid the wrapper-task overhead.
|
|
566
|
+
try:
|
|
567
|
+
async with asyncio.timeout(timeout):
|
|
568
|
+
result = await future
|
|
569
|
+
except TimeoutError:
|
|
570
|
+
async with self._lock:
|
|
571
|
+
self._router.pop(correlation_id)
|
|
572
|
+
raise RPCTimeoutError(correlation_id, timeout) from None
|
|
573
|
+
|
|
574
|
+
async with self._lock:
|
|
575
|
+
self._router.pop(correlation_id)
|
|
576
|
+
|
|
577
|
+
return result
|
|
578
|
+
|
|
579
|
+
async def close(self) -> None:
|
|
580
|
+
"""Close the RPC client and clean up.
|
|
581
|
+
|
|
582
|
+
Cancels the reply consumer and resolves all pending calls with a
|
|
583
|
+
typed ``RPCClientClosed`` (L6) — matching the sync client's
|
|
584
|
+
behavior — so an in-flight caller's ``await future`` raises cleanly
|
|
585
|
+
instead of a bare ``asyncio.CancelledError`` that could be confused
|
|
586
|
+
with the caller's own cancellation.
|
|
587
|
+
"""
|
|
588
|
+
if self._consumer_tag and self._transport:
|
|
589
|
+
try:
|
|
590
|
+
await self._transport.cancel_consumer(self._consumer_tag)
|
|
591
|
+
except Exception as e:
|
|
592
|
+
logger.warning("Failed to cancel RPC reply consumer: %s", e)
|
|
593
|
+
|
|
594
|
+
# Clean up pending calls. L6: mark closed so subsequent
|
|
595
|
+
# call()/_ensure_consuming() refuse to re-register a consumer on the
|
|
596
|
+
# torn-down client.
|
|
597
|
+
async with self._lock:
|
|
598
|
+
self._closed = True
|
|
599
|
+
self._router.close_all(RPCClientClosed("AsyncRPCClient was closed"))
|
|
600
|
+
|
|
601
|
+
self._consuming = False
|
|
602
|
+
self._consumer_tag = None
|
|
603
|
+
|
|
604
|
+
async def _ensure_consuming(self) -> None:
|
|
605
|
+
"""Ensure the reply consumer is running.
|
|
606
|
+
|
|
607
|
+
Uses the existing lock to prevent concurrent first-calls from each
|
|
608
|
+
registering a duplicate consumer on amq.rabbitmq.reply-to (which
|
|
609
|
+
supports exactly one consumer per channel).
|
|
610
|
+
"""
|
|
611
|
+
# L6: refuse to (re-)register a consumer on a closed client.
|
|
612
|
+
if self._closed:
|
|
613
|
+
raise RPCClientClosed("AsyncRPCClient is closed")
|
|
614
|
+
async with self._lock:
|
|
615
|
+
if self._consuming:
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
async def on_reply(message: RabbitMessage) -> None:
|
|
619
|
+
"""Handle reply messages — delegate correlation match to the router."""
|
|
620
|
+
async with self._lock:
|
|
621
|
+
self._router.resolve(message)
|
|
622
|
+
|
|
623
|
+
# amq.rabbitmq.reply-to is a broker pseudo-queue: it rejects any
|
|
624
|
+
# Queue.Declare (declare=False) and requires a no-ack consumer
|
|
625
|
+
# (no_ack=True) — the broker auto-acks each reply on delivery.
|
|
626
|
+
self._consumer_tag = await self._transport.consume(
|
|
627
|
+
queue=self._reply_queue,
|
|
628
|
+
callback=on_reply,
|
|
629
|
+
no_ack=True,
|
|
630
|
+
declare=False,
|
|
631
|
+
)
|
|
632
|
+
self._consuming = True
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Serialization module — pluggable encode/decode."""
|
|
2
|
+
|
|
3
|
+
from rabbitkit.serialization.json import JSONSerializer
|
|
4
|
+
from rabbitkit.serialization.msgspec import MsgspecSerializer
|
|
5
|
+
from rabbitkit.serialization.pipeline import (
|
|
6
|
+
DataclassDecoder,
|
|
7
|
+
JsonParser,
|
|
8
|
+
MessageDecoder,
|
|
9
|
+
MessageParser,
|
|
10
|
+
PydanticDecoder,
|
|
11
|
+
RawDecoder,
|
|
12
|
+
SerializationPipeline,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DataclassDecoder",
|
|
17
|
+
"JSONSerializer",
|
|
18
|
+
"JsonParser",
|
|
19
|
+
"MessageDecoder",
|
|
20
|
+
"MessageParser",
|
|
21
|
+
"MsgspecSerializer",
|
|
22
|
+
"PydanticDecoder",
|
|
23
|
+
"RawDecoder",
|
|
24
|
+
"SerializationPipeline",
|
|
25
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Serializer protocol — pluggable serialization contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, TypeVar, runtime_checkable
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Serializer(Protocol[T]):
|
|
12
|
+
"""Pluggable serializer protocol.
|
|
13
|
+
|
|
14
|
+
Generic in ``T`` so callers can pin the type flowing through
|
|
15
|
+
``decode(data, target_type) -> T`` — e.g. ``Serializer[Order]`` makes
|
|
16
|
+
mypy verify ``decode`` returns ``Order``.
|
|
17
|
+
|
|
18
|
+
Implementations must provide encode() and decode() methods.
|
|
19
|
+
rabbitkit ships with JSONSerializer and MsgspecSerializer. The built-in
|
|
20
|
+
implementations work with any ``T`` (they satisfy ``Serializer[Any]``
|
|
21
|
+
structurally) because they use ``Any`` internally.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def encode(self, data: Any) -> bytes:
|
|
25
|
+
"""Serialize data to bytes."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def decode(self, data: bytes, target_type: type[T]) -> T:
|
|
29
|
+
"""Deserialize bytes to target type."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def content_type(self) -> str:
|
|
34
|
+
"""MIME content type for this serializer."""
|
|
35
|
+
...
|