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.
Files changed (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. 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
+ ...