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
@@ -0,0 +1,877 @@
1
+ """AsyncTransport — aio-pika transport adapter.
2
+
3
+ Uses aio_pika.connect_robust() for auto-reconnection.
4
+ aio-pika owns connection/channel/consumer restoration natively.
5
+
6
+ Architecture:
7
+ - Publisher connection: dedicated connection + AsyncChannelPool for concurrent
8
+ publishes without blocking consumer channels or topology operations.
9
+ - Consumer connection: dedicated connection for all subscribe operations.
10
+ Each queue gets its own channel (required for per-queue QoS).
11
+ - Topology connection: reuses consumer connection for declare/bind operations.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import contextlib
18
+ import logging
19
+ import uuid
20
+ from collections.abc import Awaitable, Callable
21
+ from typing import Any
22
+
23
+ from rabbitkit.async_.pool import AsyncConnectionPool
24
+ from rabbitkit.core.config import ConnectionConfig, PoolConfig, SecurityConfig
25
+ from rabbitkit.core.errors import ConfigurationError
26
+ from rabbitkit.core.message import RabbitMessage
27
+ from rabbitkit.core.topology import RabbitExchange, RabbitQueue
28
+ from rabbitkit.core.topology_dispatch import TopoAction, TopologyDispatcher
29
+ from rabbitkit.core.types import (
30
+ DIRECT_REPLY_TO_QUEUE,
31
+ MessageEnvelope,
32
+ PublishOutcome,
33
+ PublishStatus,
34
+ TopologyMode,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ class AsyncTransportImpl:
41
+ """aio-pika-based async transport adapter.
42
+
43
+ Uses connect_robust() for transparent auto-reconnect.
44
+ Publisher and consumer traffic run on separate connections to prevent
45
+ head-of-line blocking and QoS interference.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ connection_config: ConnectionConfig | None = None,
51
+ security_config: SecurityConfig | None = None,
52
+ pool_config: PoolConfig | None = None,
53
+ topology_mode: TopologyMode = TopologyMode.AUTO_DECLARE,
54
+ confirm_delivery: bool = True,
55
+ confirm_timeout: float = 5.0,
56
+ on_topology_conflict: str = "raise",
57
+ ) -> None:
58
+ self._connection_config = connection_config or ConnectionConfig()
59
+ self._security_config = security_config or SecurityConfig()
60
+ self._pool_config = pool_config or PoolConfig()
61
+ self._topology_mode = topology_mode
62
+ self._topo = TopologyDispatcher(topology_mode)
63
+ # M14: "raise" | "warn_continue" on a 406 topology-drift conflict.
64
+ self._on_topology_conflict = on_topology_conflict
65
+ self._confirm_delivery = confirm_delivery
66
+ # Per-publish timeout when publisher confirms are enabled. Without this a
67
+ # broker that never confirms would block the publish coroutine forever;
68
+ # on timeout we release/close the channel and return PublishStatus.TIMEOUT.
69
+ self._confirm_timeout = float(confirm_timeout)
70
+
71
+ self._conn_pool = AsyncConnectionPool(
72
+ self._connection_config,
73
+ self._security_config,
74
+ self._pool_config,
75
+ publisher_confirms=self._confirm_delivery,
76
+ )
77
+ self._connected = False
78
+
79
+ # Per-queue consumer channels: queue_name -> aio_pika channel
80
+ self._consumer_channels: dict[str, Any] = {}
81
+ self._consumer_tags: dict[str, str] = {} # queue_name -> consumer_tag
82
+
83
+ # The channel currently consuming DIRECT_REPLY_TO_QUEUE (set by
84
+ # consume(declare=False), cleared on cancel/disconnect). RabbitMQ's
85
+ # direct reply-to requires the reply consumer and the corresponding
86
+ # request publish to happen on the SAME channel (a publish on a
87
+ # different channel raises "PRECONDITION_FAILED - fast reply consumer
88
+ # does not exist") — publish() checks this to route RPC requests
89
+ # correctly without RPCClient needing to know about channels at all.
90
+ self._reply_to_channel: Any = None
91
+
92
+ # Shared topology channel (consumer connection)
93
+ self._topology_channel: Any | None = None
94
+
95
+ # Persistent no-confirm publish channel — reused across all fire-and-forget
96
+ # publishes when confirm_delivery=False. Eliminates per-publish pool
97
+ # acquire/release overhead; a single channel handles concurrent writes
98
+ # safely because aio-pika serialises AMQP frames at the connection level.
99
+ self._fast_publish_channel: Any | None = None
100
+ self._fast_channel_lock: asyncio.Lock = asyncio.Lock()
101
+
102
+ # H1: dedicated, always-confirmed channel for mandatory=True publishes,
103
+ # independent of confirm_delivery. Detecting an unroutable Basic.Return
104
+ # reliably requires BOTH publisher confirms AND on_return_raises=True —
105
+ # neither the fast channel (no confirms at all) nor the regular pool
106
+ # (confirms follow confirm_delivery, which may be False) guarantee that.
107
+ self._mandatory_publish_channel: Any | None = None
108
+ self._mandatory_channel_lock: asyncio.Lock = asyncio.Lock()
109
+
110
+ # Backpressure callbacks (FlowController registers here). Each is a
111
+ # zero-arg callable; aio-pika's blocked/unblocked frames are adapted.
112
+ self._blocked_callbacks: list[Callable[[], None]] = []
113
+ self._unblocked_callbacks: list[Callable[[], None]] = []
114
+
115
+ # Connection-churn metric hook (see on_reconnect) -- adapted from
116
+ # aio-pika's RobustConnection.reconnect_callbacks.
117
+ self._reconnect_callbacks: list[Callable[[], None]] = []
118
+
119
+ # L15: passive blocked-state tracking, independent of whether a
120
+ # FlowController is registered above -- health.broker_health_check
121
+ # reads this (via the is_blocked property) so a broker/disk/memory
122
+ # alarm is visible even when the caller never opted into FlowController.
123
+ self._blocked_state: bool = False
124
+
125
+ def on_reconnect(self, callback: Callable[[], None]) -> None:
126
+ """Register a callback fired on every ``connect_robust`` re-connection
127
+ (connection-churn metric hook). Reconnects were logged but never
128
+ counted, so a flapping broker/network was invisible to metrics
129
+ alerting."""
130
+ self._reconnect_callbacks.append(callback)
131
+
132
+ def _aio_reconnected(self, *_args: Any) -> None:
133
+ for cb in list(self._reconnect_callbacks):
134
+ try:
135
+ cb()
136
+ except Exception: # pragma: no cover - never break the event loop
137
+ logger.exception("reconnect callback raised")
138
+
139
+ def on_blocked(self, callback: Callable[[], None]) -> None:
140
+ """Register a connection-blocked callback (e.g. FlowController.on_blocked)."""
141
+ self._blocked_callbacks.append(callback)
142
+
143
+ def on_unblocked(self, callback: Callable[[], None]) -> None:
144
+ """Register a connection-unblocked callback (e.g. FlowController.on_unblocked)."""
145
+ self._unblocked_callbacks.append(callback)
146
+
147
+ @property
148
+ def is_blocked(self) -> bool:
149
+ """True if RabbitMQ has sent ``connection.blocked`` (L15) -- e.g. a
150
+ broker memory/disk alarm. Tracked passively regardless of whether
151
+ any ``on_blocked``/``on_unblocked`` callback is registered, so
152
+ ``health.broker_health_check`` can see it even without an opt-in
153
+ ``FlowController``."""
154
+ return self._blocked_state
155
+
156
+ def _aio_blocked(self, *_args: Any) -> None:
157
+ self._blocked_state = True
158
+ for cb in list(self._blocked_callbacks):
159
+ try:
160
+ cb()
161
+ except Exception: # pragma: no cover - never break the event loop
162
+ logger.exception("blocked callback raised")
163
+
164
+ def _aio_unblocked(self, *_args: Any) -> None:
165
+ self._blocked_state = False
166
+ for cb in list(self._unblocked_callbacks):
167
+ try:
168
+ cb()
169
+ except Exception: # pragma: no cover
170
+ logger.exception("unblocked callback raised")
171
+
172
+ async def connect(self) -> None:
173
+ """Establish publisher and consumer connections."""
174
+ if self._connected:
175
+ return
176
+
177
+ await self._conn_pool.connect()
178
+
179
+ # Register connection blocked/unblocked callbacks (C-6) so a
180
+ # FlowController can throttle publishes when RabbitMQ raises an alarm.
181
+ pub_conn = await self._conn_pool.get_publisher_connection()
182
+ try:
183
+ pub_conn.connection_blocked.add_callback(self._aio_blocked)
184
+ pub_conn.connection_unblocked.add_callback(self._aio_unblocked)
185
+ except Exception: # pragma: no cover - older aio-pika may differ
186
+ logger.debug("Could not register blocked/unblocked callbacks")
187
+
188
+ # Connection-churn metric hook: connect_robust reconnects silently
189
+ # (well, logged) -- count them so a flapping broker/network is
190
+ # visible to metrics alerting. Both connections, since either can
191
+ # flap independently.
192
+ try:
193
+ pub_conn.reconnect_callbacks.add(self._aio_reconnected)
194
+ consumer_conn_for_cb = await self._conn_pool.get_consumer_connection()
195
+ if consumer_conn_for_cb is not pub_conn:
196
+ consumer_conn_for_cb.reconnect_callbacks.add(self._aio_reconnected)
197
+ except Exception: # pragma: no cover - older aio-pika may differ
198
+ logger.debug("Could not register reconnect callbacks")
199
+
200
+ # I-11: install a blocked-connection watchdog so a broker alarm that isn't
201
+ # cleared within blocked_connection_timeout closes the connection (forcing
202
+ # reconnect) — aio-pika has no native knob for this.
203
+ try:
204
+ from rabbitkit.async_.connection import install_blocked_connection_watchdog
205
+
206
+ await install_blocked_connection_watchdog(pub_conn, self._connection_config.blocked_connection_timeout)
207
+ except Exception: # pragma: no cover - best effort
208
+ logger.debug("Could not install blocked-connection watchdog")
209
+
210
+ # Open topology channel on consumer connection
211
+ consumer_conn = await self._conn_pool.get_consumer_connection()
212
+ self._topology_channel = await consumer_conn.channel()
213
+
214
+ self._connected = True
215
+ logger.info(
216
+ "Connected to RabbitMQ at %s:%d (async, pooled)",
217
+ self._connection_config.host,
218
+ self._connection_config.port,
219
+ )
220
+
221
+ async def __aenter__(self) -> AsyncTransportImpl:
222
+ await self.connect()
223
+ return self
224
+
225
+ async def __exit__(self, *args: Any) -> None:
226
+ await self.disconnect()
227
+
228
+ async def disconnect(self) -> None:
229
+ """Close all channels and connections."""
230
+ if not self._connected:
231
+ return
232
+
233
+ try:
234
+ # Close per-queue consumer channels
235
+ for ch in list(self._consumer_channels.values()):
236
+ try:
237
+ if not ch.is_closed:
238
+ await ch.close()
239
+ except Exception:
240
+ pass
241
+ self._consumer_channels.clear()
242
+ self._consumer_tags.clear()
243
+ self._reply_to_channel = None
244
+
245
+ # Close topology channel
246
+ if self._topology_channel is not None and not self._topology_channel.is_closed:
247
+ try:
248
+ await self._topology_channel.close()
249
+ except Exception:
250
+ pass
251
+ self._topology_channel = None
252
+
253
+ # Close fast publish channel (no-confirm persistent path)
254
+ if self._fast_publish_channel is not None and not self._fast_publish_channel.is_closed:
255
+ try:
256
+ await self._fast_publish_channel.close()
257
+ except Exception: # pragma: no cover — best effort close, network errors only
258
+ pass
259
+ self._fast_publish_channel = None
260
+
261
+ # Close the dedicated mandatory-publish channel (H1)
262
+ if self._mandatory_publish_channel is not None and not self._mandatory_publish_channel.is_closed:
263
+ try:
264
+ await self._mandatory_publish_channel.close()
265
+ except Exception: # pragma: no cover — best effort close, network errors only
266
+ pass
267
+ self._mandatory_publish_channel = None
268
+
269
+ await self._conn_pool.close_all()
270
+ except Exception as e:
271
+ logger.warning("Error during disconnect: %s", e)
272
+ finally:
273
+ self._connected = False
274
+ logger.info("Disconnected from RabbitMQ (async)")
275
+
276
+ def is_connected(self) -> bool:
277
+ """Check if connected to RabbitMQ.
278
+
279
+ Reflects the real underlying robust-connection state rather than a
280
+ stale cached flag: if our cached flag is False we return False;
281
+ otherwise we inspect the robust connection's ``is_closed`` attribute
282
+ (guarded) so a connection that aio-pika has silently dropped is not
283
+ reported as healthy.
284
+ """
285
+ if not self._connected:
286
+ return False
287
+ conn = self._conn_pool._publisher_connection
288
+ if conn is None:
289
+ return False
290
+ try:
291
+ # RobustConnection exposes ``is_closed``; True means fully closed.
292
+ if bool(getattr(conn, "is_closed", False)):
293
+ return False
294
+ except Exception: # pragma: no cover — defensive
295
+ return False
296
+ return True
297
+
298
+ @property
299
+ def has_open_channels(self) -> bool:
300
+ """True if at least one consumer channel is open (readiness contract).
301
+
302
+ Mirrors ``SyncTransport.has_open_channels`` so ``broker_readiness`` can
303
+ detect a dead consumer channel on async transports (I-5 async side).
304
+ """
305
+ if not self._consumer_channels:
306
+ return False
307
+ return all(not bool(getattr(ch, "is_closed", False)) for ch in self._consumer_channels.values())
308
+
309
+ @property
310
+ def is_reconnecting(self) -> bool:
311
+ """Best-effort: True if the robust connection is mid-reconnect.
312
+
313
+ aio-pika does not expose a stable public attribute, so this is a
314
+ cheap, guarded heuristic (``reconnects`` counter / ``_reconnect_lock").
315
+ """
316
+ conn = self._conn_pool._publisher_connection
317
+ if conn is None:
318
+ return False
319
+ try:
320
+ # Newer aio-pika tracks pending reconnects via a lock/event.
321
+ lock = getattr(conn, "_reconnect_lock", None)
322
+ if lock is not None and getattr(lock, "locked", lambda: False)():
323
+ return True
324
+ except Exception: # pragma: no cover
325
+ pass
326
+ return False
327
+
328
+ async def _ensure_connected(self) -> None:
329
+ """Ensure connection is established."""
330
+ if self._connected:
331
+ return
332
+ await self.connect()
333
+
334
+ async def _get_fast_channel(self) -> Any:
335
+ """Return the persistent no-confirm publish channel, (re)opening if needed.
336
+
337
+ Used exclusively by the fire-and-forget publish path (confirm_delivery=False).
338
+ A single channel is reused across all concurrent publishes; aio-pika
339
+ serialises AMQP frames at the transport level so concurrent writes are safe.
340
+ """
341
+ ch = self._fast_publish_channel
342
+ if ch is not None and not ch.is_closed:
343
+ return ch
344
+ async with self._fast_channel_lock:
345
+ ch = self._fast_publish_channel
346
+ if ch is not None and not ch.is_closed: # pragma: no cover — concurrent path
347
+ return ch
348
+ conn = self._conn_pool._publisher_connection
349
+ if conn is None:
350
+ raise RuntimeError("Publisher connection is not available")
351
+ self._fast_publish_channel = await conn.channel(publisher_confirms=False)
352
+ return self._fast_publish_channel
353
+
354
+ async def _get_mandatory_channel(self) -> Any:
355
+ """Return the dedicated always-confirmed channel for mandatory=True
356
+ publishes, (re)opening if needed.
357
+
358
+ H1: ``on_return_raises=True`` makes an unroutable ``Basic.Return``
359
+ raise ``aio_pika.exceptions.PublishError`` (caught by
360
+ :meth:`_publish_on_channel` and mapped to ``PublishStatus.RETURNED``)
361
+ instead of silently resolving the confirmation with the returned
362
+ message — indistinguishable from success otherwise. This channel is
363
+ used for every ``mandatory=True`` publish regardless of the broker's
364
+ ``confirm_delivery`` setting, since reliable return detection needs
365
+ confirms + on_return_raises unconditionally.
366
+ """
367
+ ch = self._mandatory_publish_channel
368
+ if ch is not None and not ch.is_closed:
369
+ return ch
370
+ async with self._mandatory_channel_lock:
371
+ ch = self._mandatory_publish_channel
372
+ if ch is not None and not ch.is_closed: # pragma: no cover — concurrent path
373
+ return ch
374
+ conn = self._conn_pool._publisher_connection
375
+ if conn is None:
376
+ raise RuntimeError("Publisher connection is not available")
377
+ self._mandatory_publish_channel = await conn.channel(
378
+ publisher_confirms=True, on_return_raises=True
379
+ )
380
+ return self._mandatory_publish_channel
381
+
382
+ def _build_aio_message(self, envelope: MessageEnvelope) -> Any:
383
+ """Build an aio_pika.Message from a MessageEnvelope."""
384
+ import aio_pika
385
+
386
+ return aio_pika.Message(
387
+ body=envelope.body,
388
+ message_id=envelope.message_id,
389
+ correlation_id=envelope.correlation_id,
390
+ reply_to=envelope.reply_to,
391
+ content_type=envelope.content_type,
392
+ content_encoding=envelope.content_encoding,
393
+ headers=envelope.headers or None,
394
+ delivery_mode=aio_pika.DeliveryMode(envelope.delivery_mode),
395
+ priority=envelope.priority,
396
+ expiration=(int(envelope.expiration) / 1000 if envelope.expiration else None),
397
+ type=envelope.type,
398
+ user_id=envelope.user_id,
399
+ app_id=envelope.app_id,
400
+ timestamp=envelope.timestamp,
401
+ )
402
+
403
+ async def _publish_on_channel(self, channel: Any, envelope: MessageEnvelope) -> PublishOutcome:
404
+ """Publish *envelope* on an already-acquired channel.
405
+
406
+ Used by BatchPublisher to publish many messages on one channel and
407
+ gather all confirms concurrently — one pool acquire/release for N
408
+ messages instead of N separate round-trips.
409
+
410
+ H1: a ``mandatory=True`` publish that the broker cannot route raises
411
+ ``aio_pika.exceptions.PublishError`` when the channel has
412
+ ``on_return_raises=True`` (only true for channels obtained via
413
+ :meth:`_get_mandatory_channel` — regular pool/fast channels default to
414
+ ``on_return_raises=False`` and would otherwise resolve the confirmation
415
+ with the returned message instead of raising, indistinguishable from
416
+ success). Mapped to ``PublishStatus.RETURNED`` so callers keying off
417
+ ``outcome.ok`` correctly treat it as a failed publish. A broker-side
418
+ ``Basic.Nack`` (not a return) raises the more generic
419
+ ``DeliveryError`` and maps to ``PublishStatus.NACKED``.
420
+ """
421
+ import aio_pika.exceptions
422
+
423
+ message = self._build_aio_message(envelope)
424
+ exchange = (
425
+ await channel.get_exchange(envelope.exchange, ensure=False)
426
+ if envelope.exchange
427
+ else channel.default_exchange
428
+ )
429
+ try:
430
+ async with asyncio.timeout(self._confirm_timeout):
431
+ await exchange.publish(
432
+ message,
433
+ routing_key=envelope.routing_key,
434
+ mandatory=envelope.mandatory,
435
+ )
436
+ except TimeoutError as e:
437
+ # M17: do NOT close the channel here. This coroutine may be one of
438
+ # several concurrent calls sharing the SAME channel (BatchPublisher's
439
+ # _flush gathers N of these on one channel) — closing it the instant
440
+ # OUR OWN confirm-wait times out would kill every sibling publish
441
+ # still awaiting ITS OWN confirm on that channel, even ones that
442
+ # would have confirmed cleanly a moment later. Callers decide
443
+ # whether/when to close a channel that had a timeout, and do so
444
+ # only once they know no other publish is still using it (e.g.
445
+ # after their own single call, or after a whole gathered batch has
446
+ # fully resolved) — see callers of _publish_on_channel.
447
+ logger.warning("Publish confirm timed out after %.1fs", self._confirm_timeout)
448
+ return PublishOutcome(
449
+ status=PublishStatus.TIMEOUT,
450
+ exchange=envelope.exchange,
451
+ routing_key=envelope.routing_key,
452
+ error=e,
453
+ )
454
+ except aio_pika.exceptions.PublishError as e:
455
+ logger.warning(
456
+ "Publish returned as unroutable (mandatory=True, no matching binding): "
457
+ "exchange=%s routing_key=%s",
458
+ envelope.exchange,
459
+ envelope.routing_key,
460
+ )
461
+ return PublishOutcome(
462
+ status=PublishStatus.RETURNED,
463
+ exchange=envelope.exchange,
464
+ routing_key=envelope.routing_key,
465
+ error=e,
466
+ )
467
+ except aio_pika.exceptions.DeliveryError as e:
468
+ logger.warning(
469
+ "Publish nacked by broker: exchange=%s routing_key=%s",
470
+ envelope.exchange,
471
+ envelope.routing_key,
472
+ )
473
+ return PublishOutcome(
474
+ status=PublishStatus.NACKED,
475
+ exchange=envelope.exchange,
476
+ routing_key=envelope.routing_key,
477
+ error=e,
478
+ )
479
+ return PublishOutcome(
480
+ status=PublishStatus.CONFIRMED,
481
+ exchange=envelope.exchange,
482
+ routing_key=envelope.routing_key,
483
+ )
484
+
485
+ async def publish(self, envelope: MessageEnvelope) -> PublishOutcome:
486
+ """Publish a message.
487
+
488
+ When confirm_delivery=False: uses a single persistent channel (no
489
+ acquire/release overhead, no broker ACK wait) for maximum throughput.
490
+ When confirm_delivery=True: uses the channel pool so each in-flight
491
+ confirm is isolated to its own channel slot.
492
+
493
+ A request with ``reply_to=DIRECT_REPLY_TO_QUEUE`` (RPCClient's direct
494
+ reply-to requests) bypasses both paths above and is routed onto
495
+ ``self._reply_to_channel`` — the same channel that registered the
496
+ reply consumer — instead. RabbitMQ requires this exact channel
497
+ affinity for direct reply-to; publishing on a different channel raises
498
+ "PRECONDITION_FAILED - fast reply consumer does not exist".
499
+
500
+ H1: a ``mandatory=True`` envelope (that isn't a direct reply-to
501
+ request) always publishes via the dedicated always-confirmed channel
502
+ from :meth:`_get_mandatory_channel`, regardless of ``confirm_delivery``
503
+ — see that method's docstring for why neither the fast nor the regular
504
+ pool channel can reliably report an unroutable ``Basic.Return``.
505
+ """
506
+ try:
507
+ if not self._connected:
508
+ await self._ensure_connected()
509
+
510
+ if envelope.reply_to == DIRECT_REPLY_TO_QUEUE and self._reply_to_channel is not None:
511
+ channel = self._reply_to_channel
512
+ if not channel.is_closed:
513
+ return await self._publish_on_channel(channel, envelope)
514
+
515
+ if envelope.mandatory:
516
+ channel = await self._get_mandatory_channel()
517
+ outcome = await self._publish_on_channel(channel, envelope)
518
+ # M17: this channel is a single persistent channel reused across
519
+ # ALL mandatory publishes — close it AFTER our own call resolves
520
+ # (not from inside _publish_on_channel) so a timeout doesn't risk
521
+ # yanking a channel a concurrent mandatory publish is still using.
522
+ # _get_mandatory_channel() lazily reopens on the next call.
523
+ if outcome.status == PublishStatus.TIMEOUT and not channel.is_closed:
524
+ with contextlib.suppress(Exception):
525
+ await channel.close()
526
+ return outcome
527
+
528
+ if not self._confirm_delivery:
529
+ # Fast path: persistent channel, no confirm wait, no pool overhead
530
+ message = self._build_aio_message(envelope)
531
+ channel = await self._get_fast_channel()
532
+ exchange = (
533
+ await channel.get_exchange(envelope.exchange, ensure=False)
534
+ if envelope.exchange
535
+ else channel.default_exchange
536
+ )
537
+ await exchange.publish(
538
+ message,
539
+ routing_key=envelope.routing_key,
540
+ mandatory=envelope.mandatory,
541
+ )
542
+ # M4: SENT, not CONFIRMED -- this channel has publisher_confirms=False,
543
+ # so nothing was actually acknowledged by the broker.
544
+ return PublishOutcome(
545
+ status=PublishStatus.SENT,
546
+ exchange=envelope.exchange,
547
+ routing_key=envelope.routing_key,
548
+ )
549
+
550
+ # Confirmed path: pool channel per publish so each confirm is isolated
551
+ channel = await self._conn_pool.acquire_publisher_channel()
552
+ try:
553
+ outcome = await self._publish_on_channel(channel, envelope)
554
+ # M17: close a timed-out channel before returning it to the pool
555
+ # so the pool doesn't hand out a possibly-wedged channel next.
556
+ # This channel is exclusively ours for this single publish (not
557
+ # shared concurrently), so closing here — after our own call has
558
+ # fully resolved — is safe.
559
+ if outcome.status == PublishStatus.TIMEOUT and not channel.is_closed:
560
+ with contextlib.suppress(Exception):
561
+ await channel.close()
562
+ return outcome
563
+ finally:
564
+ await self._conn_pool.release_publisher_channel(channel)
565
+
566
+ except Exception as e:
567
+ logger.error("Async publish failed: %s", e)
568
+ return PublishOutcome(
569
+ status=PublishStatus.ERROR,
570
+ exchange=envelope.exchange,
571
+ routing_key=envelope.routing_key,
572
+ error=e,
573
+ )
574
+ async def consume(
575
+ self,
576
+ queue: str,
577
+ callback: Callable[[RabbitMessage], Awaitable[None]],
578
+ prefetch: int = 10,
579
+ *,
580
+ no_ack: bool = False,
581
+ declare: bool = True,
582
+ ) -> str:
583
+ """Start consuming from a queue.
584
+
585
+ Each queue gets a dedicated channel so per-queue QoS settings do not
586
+ interfere with each other. Returns the consumer tag.
587
+
588
+ ``no_ack=True`` starts a no-ack consumer: the broker auto-acks on
589
+ delivery, and the built ``RabbitMessage`` is not wired with settlement
590
+ functions (there is nothing to ack/nack/reject).
591
+
592
+ ``declare=False`` skips the passive-declare check and instead obtains a
593
+ bare, undeclared ``Queue`` handle (``channel.get_queue(queue,
594
+ ensure=False)`` — constructs the wrapper locally with no AMQP frame
595
+ sent, unlike ``declare_queue(passive=True)``). Required for AMQP
596
+ pseudo-queues such as ``amq.rabbitmq.reply-to``: the broker rejects
597
+ *any* Queue.Declare against that name (even passive), yet basic_consume
598
+ against it is valid and is how RabbitMQ's direct-reply-to feature works.
599
+ Note: an undeclared queue is not tracked in ``RobustChannel``'s
600
+ internal registry, so unlike the ``declare=True`` path this consumer is
601
+ NOT automatically resumed by aio-pika after a ``connect_robust``
602
+ reconnect — acceptable for ``amq.rabbitmq.reply-to``, whose lifetime is
603
+ scoped to the connection anyway.
604
+
605
+ When ``declare=False`` and ``queue == DIRECT_REPLY_TO_QUEUE``, this
606
+ consumer's channel is also remembered as ``self._reply_to_channel`` so
607
+ :meth:`publish` can route matching requests onto the SAME channel —
608
+ required by RabbitMQ's direct reply-to (see :meth:`publish`).
609
+ """
610
+ await self._ensure_connected()
611
+
612
+ # Dedicated channel per consumer queue for isolated QoS
613
+ consumer_conn = await self._conn_pool.get_consumer_connection()
614
+ channel = await consumer_conn.channel()
615
+ await channel.set_qos(prefetch_count=prefetch)
616
+ self._consumer_channels[queue] = channel
617
+
618
+ if not declare and queue == DIRECT_REPLY_TO_QUEUE:
619
+ self._reply_to_channel = channel
620
+
621
+ if declare:
622
+ # passive declare (not get_queue): RobustChannel only restores queues
623
+ # in its _queues registry, which declare_queue populates and get_queue
624
+ # does not. Without this, the consumer is silently NOT resumed after a
625
+ # connect_robust reconnect (the queue — and its consumer — are
626
+ # untracked).
627
+ q = await channel.declare_queue(queue, passive=True)
628
+ else:
629
+ # No AMQP frame sent — just a local Queue wrapper for `queue`. Some
630
+ # pseudo-queues (amq.rabbitmq.reply-to) reject any Queue.Declare.
631
+ q = await channel.get_queue(queue, ensure=False)
632
+ consumer_tag = f"rabbitkit.{uuid.uuid4()}"
633
+
634
+ async def on_message(message: Any) -> None:
635
+ rabbit_msg = self._build_message(message, no_ack=no_ack)
636
+ await callback(rabbit_msg)
637
+
638
+ await q.consume(on_message, consumer_tag=consumer_tag, no_ack=no_ack)
639
+ self._consumer_tags[queue] = consumer_tag
640
+ logger.info("Started consuming from queue '%s' with tag '%s' (async)", queue, consumer_tag)
641
+ return consumer_tag
642
+
643
+ async def declare_exchange(self, exchange: RabbitExchange) -> None:
644
+ """Declare an exchange on the topology channel."""
645
+ action = self._topo.exchange_action(exchange)
646
+ if action is TopoAction.SKIP:
647
+ return
648
+
649
+ await self._ensure_connected()
650
+ assert self._topology_channel is not None
651
+
652
+ kwargs = exchange.to_declare_kwargs()
653
+
654
+ import aio_pika.exceptions
655
+
656
+ try:
657
+ if action is TopoAction.PASSIVE:
658
+ await self._topology_channel.get_exchange(kwargs["exchange"], ensure=True)
659
+ else:
660
+ await self._topology_channel.declare_exchange(
661
+ name=kwargs["exchange"],
662
+ type=kwargs.get("exchange_type", "direct"),
663
+ durable=kwargs.get("durable", True),
664
+ auto_delete=kwargs.get("auto_delete", False),
665
+ internal=kwargs.get("internal", False),
666
+ arguments=kwargs.get("arguments"),
667
+ )
668
+ except aio_pika.exceptions.ChannelPreconditionFailed as exc:
669
+ await self._handle_precondition_failed("exchange", kwargs["exchange"], exc)
670
+
671
+ async def declare_queue(self, queue: RabbitQueue) -> None:
672
+ """Declare a queue on the topology channel."""
673
+ action = self._topo.queue_action(queue)
674
+ if action is TopoAction.SKIP:
675
+ return
676
+
677
+ await self._ensure_connected()
678
+ assert self._topology_channel is not None
679
+
680
+ kwargs = queue.to_declare_kwargs()
681
+
682
+ import aio_pika.exceptions
683
+
684
+ try:
685
+ if action is TopoAction.PASSIVE:
686
+ await self._topology_channel.get_queue(kwargs["queue"], ensure=True)
687
+ else:
688
+ await self._topology_channel.declare_queue(
689
+ name=kwargs["queue"],
690
+ durable=kwargs.get("durable", True),
691
+ exclusive=kwargs.get("exclusive", False),
692
+ auto_delete=kwargs.get("auto_delete", False),
693
+ arguments=kwargs.get("arguments"),
694
+ )
695
+ except aio_pika.exceptions.ChannelPreconditionFailed as exc:
696
+ await self._handle_precondition_failed("queue", kwargs["queue"], exc)
697
+
698
+ async def _handle_precondition_failed(self, kind: str, name: str, exc: BaseException) -> None:
699
+ """M6: turn a 406 PRECONDITION_FAILED into a typed, actionable error.
700
+
701
+ Declaring a queue/exchange with arguments that conflict with an
702
+ existing one of the same name (e.g. an ops-created quorum queue
703
+ where rabbitkit's config declares classic, or a different TTL/DLX)
704
+ closes the channel with reply_code 406 --
705
+ ``aio_pika.exceptions.ChannelPreconditionFailed`` specifically.
706
+ Previously this aborted startup with a low-level channel-closed
707
+ traceback giving no hint which queue/exchange or argument actually
708
+ conflicted.
709
+
710
+ M14: under ``SafetyConfig.on_topology_conflict="warn_continue"`` the
711
+ 406 is logged and swallowed — a 406 (unlike a 404) proves the entity
712
+ exists, so rabbitkit continues with the EXISTING definition. The
713
+ conflict closed the topology channel, so we reopen it first.
714
+ """
715
+ if self._on_topology_conflict == "warn_continue":
716
+ consumer_conn = await self._conn_pool.get_consumer_connection()
717
+ self._topology_channel = await consumer_conn.channel()
718
+ logger.warning(
719
+ "Topology drift on %s %r (broker: %s); on_topology_conflict='warn_continue' "
720
+ "— continuing with the EXISTING definition (rabbitkit's declaration was NOT "
721
+ "applied). Reconcile the %s or fix its rabbitkit config to silence this.",
722
+ kind,
723
+ name,
724
+ exc,
725
+ kind,
726
+ )
727
+ return
728
+ raise ConfigurationError(
729
+ f"Cannot declare {kind} {name!r}: it already exists with incompatible "
730
+ f"arguments (broker said: {exc}). This usually means it was created "
731
+ f"outside rabbitkit (e.g. ops tooling) with different arguments (e.g. "
732
+ f"quorum vs classic queue type, a different TTL, or a different "
733
+ f"dead-letter exchange). Either delete/reconcile the existing {kind}, "
734
+ f"adjust its rabbitkit definition to match, or use "
735
+ f"TopologyMode.PASSIVE_ONLY to skip declaration and just verify it exists."
736
+ ) from exc
737
+
738
+ async def bind_queue(
739
+ self,
740
+ queue: str,
741
+ exchange: str,
742
+ routing_key: str,
743
+ arguments: dict[str, Any] | None = None,
744
+ ) -> None:
745
+ """Bind a queue to an exchange on the topology channel.
746
+
747
+ ``arguments`` carries header-match criteria for HEADERS exchanges
748
+ (``x-match`` etc.) — without them a headers binding matches every
749
+ message (C4).
750
+ """
751
+ if self._topo.binding_action() is TopoAction.SKIP:
752
+ return
753
+
754
+ await self._ensure_connected()
755
+ assert self._topology_channel is not None
756
+
757
+ q = await self._topology_channel.get_queue(queue, ensure=False)
758
+ ex = await self._topology_channel.get_exchange(exchange, ensure=False)
759
+ await q.bind(ex, routing_key=routing_key, arguments=arguments)
760
+
761
+ async def bind_exchange(
762
+ self,
763
+ destination: str,
764
+ source: str,
765
+ routing_key: str = "",
766
+ arguments: dict[str, Any] | None = None,
767
+ ) -> None:
768
+ """Bind an exchange to another exchange on the topology channel."""
769
+ if self._topo.binding_action() is TopoAction.SKIP:
770
+ return
771
+
772
+ await self._ensure_connected()
773
+ assert self._topology_channel is not None
774
+
775
+ dest_ex = await self._topology_channel.get_exchange(destination, ensure=False)
776
+ src_ex = await self._topology_channel.get_exchange(source, ensure=False)
777
+ await dest_ex.bind(src_ex, routing_key=routing_key, arguments=arguments)
778
+
779
+ async def cancel_consumer(self, consumer_tag: str) -> None:
780
+ """Cancel a consumer by tag."""
781
+ if not self._connected:
782
+ return
783
+
784
+ for queue_name, tag in list(self._consumer_tags.items()):
785
+ if tag == consumer_tag:
786
+ channel = self._consumer_channels.get(queue_name)
787
+ if channel is not None:
788
+ try:
789
+ q = await channel.get_queue(queue_name, ensure=False)
790
+ await q.cancel(consumer_tag)
791
+ except Exception as e:
792
+ logger.warning("Failed to cancel consumer %s: %s", consumer_tag, e)
793
+ finally:
794
+ del self._consumer_tags[queue_name]
795
+ del self._consumer_channels[queue_name]
796
+ if channel is self._reply_to_channel:
797
+ self._reply_to_channel = None
798
+ break
799
+
800
+ # ── DLQ / inspection (DLQInspector protocol) ──────────────────────────
801
+
802
+ async def basic_get(self, queue: str) -> RabbitMessage | None:
803
+ """Get a single message without subscribing.
804
+
805
+ Used by DLQInspector for peek/replay. Returns None if the queue is empty.
806
+ """
807
+ await self._ensure_connected()
808
+ assert self._topology_channel is not None
809
+ q = await self._topology_channel.get_queue(queue, ensure=False)
810
+ aio_msg = await q.get(fail=False, no_ack=False)
811
+ if aio_msg is None:
812
+ return None
813
+ return self._build_message(aio_msg)
814
+
815
+ async def purge_queue(self, queue: str) -> int:
816
+ """Purge all messages from a queue. Returns the number of messages purged."""
817
+ await self._ensure_connected()
818
+ assert self._topology_channel is not None
819
+ q = await self._topology_channel.get_queue(queue, ensure=False)
820
+ result = await q.purge()
821
+ return int(getattr(result, "message_count", 0))
822
+
823
+ # ── Internal ──────────────────────────────────────────────────────────
824
+
825
+ def _build_message(self, aio_message: Any, *, no_ack: bool = False) -> RabbitMessage:
826
+ """Build RabbitMessage from aio-pika IncomingMessage.
827
+
828
+ ``no_ack=True`` (delivery came from a no-ack consumer) skips wiring
829
+ settlement functions entirely — the broker already auto-acked the
830
+ delivery, and aio-pika's ``IncomingMessage.ack()``/``nack()``/``reject()``
831
+ raise ``TypeError`` on a no-ack message anyway.
832
+ """
833
+ message = RabbitMessage(
834
+ body=aio_message.body,
835
+ headers=dict(aio_message.headers) if aio_message.headers else {},
836
+ message_id=aio_message.message_id,
837
+ correlation_id=aio_message.correlation_id,
838
+ reply_to=aio_message.reply_to,
839
+ content_type=aio_message.content_type,
840
+ content_encoding=aio_message.content_encoding,
841
+ type=aio_message.type,
842
+ app_id=aio_message.app_id,
843
+ priority=aio_message.priority,
844
+ # aio-pika decodes the wire's ms-string expiration into seconds
845
+ # (float) on IncomingMessage; re-encode to the ms-string convention
846
+ # RabbitMessage/MessageEnvelope.expiration use everywhere else (matches
847
+ # the raw string pika.BasicProperties.expiration carries unmodified),
848
+ # so a retry/DLQ-replay envelope built from this message round-trips
849
+ # correctly regardless of which transport received it.
850
+ expiration=(str(int(aio_message.expiration * 1000)) if aio_message.expiration is not None else None),
851
+ user_id=aio_message.user_id,
852
+ timestamp=aio_message.timestamp, # was never surfaced on consume
853
+ routing_key=aio_message.routing_key,
854
+ exchange=aio_message.exchange or "",
855
+ delivery_tag=aio_message.delivery_tag,
856
+ redelivered=aio_message.redelivered,
857
+ consumer_tag=aio_message.consumer_tag,
858
+ raw_message=aio_message,
859
+ )
860
+
861
+ if no_ack:
862
+ return message
863
+
864
+ async def ack_fn() -> None:
865
+ await aio_message.ack()
866
+
867
+ async def nack_fn(requeue: bool = True) -> None:
868
+ await aio_message.nack(requeue=requeue)
869
+
870
+ async def reject_fn(requeue: bool = False) -> None:
871
+ await aio_message.reject(requeue=requeue)
872
+
873
+ message._ack_async_fn = ack_fn
874
+ message._nack_async_fn = nack_fn
875
+ message._reject_async_fn = reject_fn
876
+
877
+ return message