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,1123 @@
1
+ """AsyncBroker — wires core registry + pipeline + AsyncTransportImpl.
2
+
3
+ The AsyncBroker is the high-level entry point for async applications.
4
+ It combines the registry (for handler registration), the pipeline
5
+ (for message processing), and the transport (for RabbitMQ I/O).
6
+
7
+ Graceful shutdown:
8
+ 1. Cancel all consumers (cancel per consumer_tag)
9
+ 2. Wait for in-flight messages
10
+ 3. Close transport connection
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import signal
18
+ import time
19
+ from collections.abc import Callable
20
+ from dataclasses import replace
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ import structlog
24
+
25
+ from rabbitkit.async_.transport import AsyncTransportImpl
26
+ from rabbitkit.concurrency import AsyncWorkerPool
27
+ from rabbitkit.core.config import (
28
+ BatchPublishConfig,
29
+ ConsumerConfig,
30
+ RabbitConfig,
31
+ RetryConfig,
32
+ RetryDisabled,
33
+ WorkerConfig,
34
+ )
35
+ from rabbitkit.core.errors import BackpressureError
36
+ from rabbitkit.core.message import RabbitMessage
37
+ from rabbitkit.core.path import extract_path, to_binding_key
38
+ from rabbitkit.core.pipeline import HandlerPipeline
39
+ from rabbitkit.core.registry import SubscriberRegistry
40
+ from rabbitkit.core.route import RouteDefinition
41
+ from rabbitkit.core.topology import RabbitExchange, RabbitQueue
42
+ from rabbitkit.core.types import (
43
+ AckPolicy,
44
+ MessageEnvelope,
45
+ PublishOutcome,
46
+ PublishStatus,
47
+ TopologyMode,
48
+ )
49
+ from rabbitkit.middleware.base import BaseMiddleware
50
+ from rabbitkit.middleware.retry import RetryRouter
51
+ from rabbitkit.serialization.base import Serializer
52
+
53
+ if TYPE_CHECKING:
54
+ from rabbitkit.core.router import RabbitRouter
55
+
56
+ logger = structlog.stdlib.get_logger(__name__)
57
+
58
+
59
+ class AsyncBroker:
60
+ """Async broker — wires registry + pipeline + AsyncTransportImpl.
61
+
62
+ Usage::
63
+
64
+ config = RabbitConfig(connection=ConnectionConfig(host="localhost"))
65
+ broker = AsyncBroker(config)
66
+
67
+ @broker.subscriber(queue="orders", exchange="events")
68
+ async def handle_order(body: bytes) -> None:
69
+ ...
70
+
71
+ await broker.start()
72
+ """
73
+
74
+ # L14: liveness heartbeat tick interval -- well under any reasonable
75
+ # health.broker_liveness(wedged_timeout=...) so idle-but-healthy periods
76
+ # never spuriously trip liveness.
77
+ _HEARTBEAT_INTERVAL: float = 5.0
78
+
79
+ def __init__(
80
+ self,
81
+ config: RabbitConfig | None = None,
82
+ *,
83
+ serializer: Serializer[Any] | None = None,
84
+ di_resolver: Any | None = None,
85
+ context_repo: Any | None = None,
86
+ batch_config: BatchPublishConfig | None = None,
87
+ middlewares: list[BaseMiddleware] | None = None,
88
+ ) -> None:
89
+ self._config = config or RabbitConfig()
90
+ # Private mutable view of consumer config — brokers may apply a
91
+ # prefetch override derived from WorkerConfig.prefetch_per_worker.
92
+ # Stored separately so the caller's frozen RabbitConfig is never mutated.
93
+ self._consumer_config = self._config.consumer
94
+
95
+ self._registry = SubscriberRegistry(broker_retry=self._config.retry)
96
+ self._pipeline = HandlerPipeline(
97
+ serializer=serializer,
98
+ di_resolver=di_resolver,
99
+ context_repo=context_repo,
100
+ reject_transient_on_redelivery=self._config.consumer.reject_transient_on_redelivery,
101
+ )
102
+
103
+ # C3: middlewares applied to every broker.publish() call — the primary
104
+ # producer API. Distinct from @subscriber(middlewares=[...]), which
105
+ # only wraps a route's HANDLER-RESULT publishes (Contract 5); without
106
+ # this, e.g. SigningMiddleware never signed anything published via
107
+ # broker.publish() directly. The composed chain is cached by this
108
+ # list's identity (see HandlerPipeline.compose_broker_publish_async), so
109
+ # set the full list via this constructor param — mutating it in place
110
+ # after the first publish() call would silently reuse the stale
111
+ # pre-mutation chain.
112
+ self._publish_middlewares: list[BaseMiddleware] = middlewares or []
113
+
114
+ self._batch_config = batch_config
115
+ self._batch_publisher: Any | None = None # BatchPublisher, started lazily in start()
116
+ self._transport: Any | None = None # AsyncTransportImpl
117
+ self._worker_pool: AsyncWorkerPool | None = None
118
+ self._started = False
119
+ self._rpc_client: Any | None = None
120
+
121
+ # L14: liveness heartbeat (see health.broker_liveness). None until
122
+ # start() -- see the start() docstring for why it's set there rather
123
+ # than only on delivery/tick.
124
+ self.last_heartbeat: float | None = None
125
+ self._heartbeat_task: asyncio.Task[None] | None = None
126
+ # I-16: optional callback invoked from the signal handler so an embedding
127
+ # RabbitApp's shutdown event is also set (prevents the double-install hang
128
+ # where the broker's handler overwrites the app's). Wire
129
+ # ``broker.on_app_shutdown = app.request_shutdown`` before ``app.run_async()``.
130
+ self.on_app_shutdown: Callable[[], None] | None = None
131
+
132
+ # Bounded graceful drain (C-2): inline in-flight counter guarded by an
133
+ # asyncio.Condition (R-Condition). ``_in_flight`` stays a plain int so
134
+ # health checks that read it directly keep working (backward compat).
135
+ self._in_flight = 0
136
+ self._in_flight_cond: asyncio.Condition | None = None # lazily created in loop
137
+ # Task/message pairs for in-flight INLINE consumption (no worker pool
138
+ # configured -- the default). Mirrors AsyncWorkerPool._task_messages so
139
+ # a drain-deadline timeout can cancel + nack the still-running ones
140
+ # with delivery-tag logging, the same as the pooled path already does,
141
+ # instead of silently abandoning them unacked.
142
+ self._inflight_tasks: dict[asyncio.Task[None], RabbitMessage] = {}
143
+
144
+ # Optional publish-side flow control (C-6).
145
+ self._flow_controller: Any | None = None
146
+
147
+ # Signal-handler bookkeeping (H-SRE5).
148
+ self._original_handlers: dict[signal.Signals, Any] = {}
149
+ self._installed_loop_handlers = False
150
+ self._loop: asyncio.AbstractEventLoop | None = None
151
+
152
+ # H11: shutdown event awaited by run() so the drain triggered by a
153
+ # signal (or request_shutdown()) is joined instead of fire-and-forget.
154
+ # _run_waiting is True only while run() is actually awaiting the
155
+ # event, so a signal received under bare start() usage still falls
156
+ # back to the pre-H11 fire-and-forget stop() task.
157
+ self._shutdown_event: asyncio.Event = asyncio.Event()
158
+ self._run_waiting = False
159
+
160
+ @property
161
+ def flow_controller(self) -> Any | None:
162
+ """Optional FlowController used to throttle the publish path."""
163
+ return self._flow_controller
164
+
165
+ @flow_controller.setter
166
+ def flow_controller(self, value: Any | None) -> None:
167
+ self._flow_controller = value
168
+ if self._transport is not None and value is not None:
169
+ self._transport.on_blocked(value.on_blocked)
170
+ self._transport.on_unblocked(value.on_unblocked)
171
+
172
+ def _ensure_inflight_cond(self) -> asyncio.Condition:
173
+ if self._in_flight_cond is None:
174
+ self._in_flight_cond = asyncio.Condition()
175
+ return self._in_flight_cond
176
+
177
+ def _mark_heartbeat(self) -> None:
178
+ """Refresh the liveness heartbeat (I-4/L14).
179
+
180
+ Called both per delivered message (``on_message`` in
181
+ :meth:`_start_consumer`) and periodically by ``_heartbeat_loop`` --
182
+ the latter is what keeps a healthy but message-idle consumer from
183
+ being mistaken for a wedged one by :func:`health.broker_liveness`.
184
+ """
185
+ self.last_heartbeat = time.monotonic()
186
+
187
+ async def _heartbeat_loop(self) -> None:
188
+ """L14: periodic liveness heartbeat -- the async analogue of the sync
189
+ broker's per-``start_consuming()``-tick heartbeat.
190
+
191
+ aio-pika has no exposed manual I/O-loop-tick to hook (unlike pika's
192
+ ``process_data_events``); this task ticking on its own interval is
193
+ itself the liveness signal instead -- if the event loop were
194
+ genuinely wedged (blocked, not just disconnected), this task would
195
+ not get scheduled and the heartbeat would correctly go stale. A
196
+ transient disconnect during reconnect is intentionally NOT
197
+ distinguished from "healthy but idle" here: ``broker_liveness``
198
+ documents that a transient disconnect is not itself a liveness
199
+ failure, and a reconnect attempt completes well within
200
+ ``wedged_timeout`` in practice -- only a reconnect loop stuck for the
201
+ full timeout window would (correctly) trip liveness.
202
+ """
203
+ try:
204
+ while True:
205
+ await asyncio.sleep(self._HEARTBEAT_INTERVAL)
206
+ self._mark_heartbeat()
207
+ except asyncio.CancelledError:
208
+ pass
209
+
210
+ async def _in_flight_inc(self) -> None:
211
+ async with self._ensure_inflight_cond():
212
+ self._in_flight += 1
213
+
214
+ async def _in_flight_dec(self) -> None:
215
+ cond = self._ensure_inflight_cond()
216
+ async with cond:
217
+ if self._in_flight > 0:
218
+ self._in_flight -= 1
219
+ if self._in_flight == 0:
220
+ cond.notify_all()
221
+
222
+ @property
223
+ def config(self) -> RabbitConfig:
224
+ return self._config
225
+
226
+ @property
227
+ def publish_middlewares(self) -> list[BaseMiddleware]:
228
+ """Middlewares applied to every ``broker.publish()`` call (e.g. signing).
229
+
230
+ Set via the constructor's ``middlewares=`` param. See the comment on
231
+ ``self._publish_middlewares`` for why reassigning (not mutating) is
232
+ required to change this after construction.
233
+ """
234
+ return self._publish_middlewares
235
+
236
+ @property
237
+ def routes(self) -> list[RouteDefinition]:
238
+ return self._registry.routes
239
+
240
+ @property
241
+ def worker_pool(self) -> AsyncWorkerPool | None:
242
+ """Return the worker pool (if configured)."""
243
+ return self._worker_pool
244
+
245
+ @property
246
+ def consumer_config(self) -> ConsumerConfig:
247
+ """Effective consumer config (may reflect WorkerConfig.prefetch override)."""
248
+ return self._consumer_config
249
+
250
+ # ── Registration (decorator API) ──────────────────────────────────────
251
+
252
+ def subscriber(
253
+ self,
254
+ queue: RabbitQueue | str,
255
+ exchange: RabbitExchange | str | None = None,
256
+ routing_key: str = "",
257
+ ack_policy: AckPolicy = AckPolicy.AUTO,
258
+ middlewares: list[BaseMiddleware] | None = None,
259
+ serializer: Serializer[Any] | None = None,
260
+ retry: RetryConfig | RetryDisabled | None = None,
261
+ tags: frozenset[str] | set[str] | None = None,
262
+ description: str = "",
263
+ name: str | None = None,
264
+ prefetch_count: int | None = None,
265
+ filter_fn: Callable[[RabbitMessage], bool] | None = None,
266
+ reject_without_dlx: str | None = None,
267
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
268
+ """Register a subscriber handler."""
269
+ return self._registry.subscriber(
270
+ queue=queue,
271
+ exchange=exchange,
272
+ routing_key=routing_key,
273
+ ack_policy=ack_policy,
274
+ middlewares=middlewares,
275
+ serializer=serializer,
276
+ retry=retry,
277
+ tags=tags,
278
+ description=description,
279
+ name=name,
280
+ prefetch_count=prefetch_count,
281
+ filter_fn=filter_fn,
282
+ reject_without_dlx=reject_without_dlx,
283
+ )
284
+
285
+ def publisher(
286
+ self,
287
+ exchange: RabbitExchange | str | None = None,
288
+ routing_key: str = "",
289
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
290
+ """Register a result publisher."""
291
+ return self._registry.publisher(exchange=exchange, routing_key=routing_key)
292
+
293
+ def include_router(self, router: RabbitRouter, prefix: str = "") -> None:
294
+ """Include routes from a RabbitRouter."""
295
+ self._registry.include_router(router, prefix=prefix)
296
+
297
+ # ── Lifecycle ─────────────────────────────────────────────────────────
298
+
299
+ async def start(
300
+ self,
301
+ worker_config: WorkerConfig | None = None,
302
+ *,
303
+ install_signal_handlers: bool = True,
304
+ ) -> None:
305
+ """Start the broker - connect, declare topology, start consuming.
306
+
307
+ 1. Connect to RabbitMQ
308
+ 2. Declare exchanges, queues, bindings per TopologyMode
309
+ 3. Declare retry topology (delay queues, DLQ)
310
+ 4. Optionally create a worker pool for concurrent processing
311
+ 5. Start consuming from all registered queues
312
+
313
+ When ``install_signal_handlers`` is True (default), SIGINT/SIGTERM are
314
+ trapped so the common ``await broker.start()`` pattern drains gracefully
315
+ instead of hard-dying (H-SRE5). Pass ``False`` when an outer lifecycle
316
+ manager (e.g. ``RabbitApp``) owns signal handling.
317
+
318
+ L14: ``last_heartbeat`` is initialized here (not left ``None`` until
319
+ the first message/tick) so a broker that is wedged from the very
320
+ start -- before it ever processes a message or a periodic heartbeat
321
+ tick -- is still caught by :func:`health.broker_liveness`'s
322
+ staleness check, instead of bypassing it entirely (a ``None``
323
+ heartbeat is treated as "no signal available" there, which
324
+ previously meant "always alive").
325
+ """
326
+ if self._started:
327
+ return
328
+
329
+ self.last_heartbeat = time.monotonic()
330
+
331
+ # Configure structured logging if enabled
332
+ if self._config.logging is not None:
333
+ from rabbitkit.core.logging import configure_structlog
334
+
335
+ configure_structlog(self._config.logging)
336
+
337
+ # Create transport
338
+ self._transport = AsyncTransportImpl(
339
+ connection_config=self._config.connection,
340
+ security_config=self._config.security,
341
+ topology_mode=self._config.topology_mode,
342
+ confirm_delivery=self._config.publisher.confirm_delivery,
343
+ confirm_timeout=self._config.publisher.confirm_timeout,
344
+ on_topology_conflict=self._config.safety.on_topology_conflict,
345
+ )
346
+
347
+ await self._transport.connect()
348
+
349
+ # Wire an opt-in FlowController's blocked/unblocked callbacks to the
350
+ # transport now that it exists (C-6).
351
+ if self._flow_controller is not None:
352
+ self._transport.on_blocked(self._flow_controller.on_blocked)
353
+ self._transport.on_unblocked(self._flow_controller.on_unblocked)
354
+
355
+ # M-P5: warn when confirms are on and the publisher channel pool is
356
+ # small relative to expected concurrency (default unchanged).
357
+ if self._config.publisher.confirm_delivery:
358
+ pool_size = self._config.pool.channel_pool_size
359
+ # Publisher concurrency ~ worker_count (handlers that publish) or 1.
360
+ expected = worker_config.worker_count if worker_config and worker_config.worker_count > 1 else 1
361
+ if pool_size < max(4, expected):
362
+ import warnings
363
+
364
+ warnings.warn(
365
+ f"confirm_delivery=True with channel_pool_size={pool_size} "
366
+ f"(expected publisher concurrency ~{expected}). Concurrent confirms "
367
+ "are capped by the pool size; increase PoolConfig.channel_pool_size "
368
+ "if publish throughput matters.",
369
+ RuntimeWarning,
370
+ stacklevel=2,
371
+ )
372
+
373
+ # Start batch publisher if configured (must come before topology so
374
+ # the flush task is alive when the first publish arrives).
375
+ if self._batch_config is not None:
376
+ import dataclasses
377
+ import warnings
378
+
379
+ from rabbitkit.async_.batch import AsyncBatchPublisher
380
+
381
+ batch_cfg = self._batch_config
382
+ pool_size = self._config.pool.channel_pool_size
383
+ if batch_cfg.flush_workers == 0:
384
+ # Auto-compute workers but cap at half the pool so at least
385
+ # half the channels remain available for retry/direct publishes.
386
+ # Batch workers hold their channels permanently; exhausting the
387
+ # pool deadlocks any non-batch transport.publish() call (e.g. retry).
388
+ auto = min(16, max(1, batch_cfg.max_in_flight // batch_cfg.batch_size))
389
+ safe = min(auto, max(1, pool_size // 2))
390
+ batch_cfg = dataclasses.replace(batch_cfg, flush_workers=safe)
391
+ elif batch_cfg.flush_workers > pool_size // 2:
392
+ warnings.warn(
393
+ f"BatchPublishConfig.flush_workers={batch_cfg.flush_workers} > "
394
+ f"channel_pool_size({pool_size}) // 2. Batch workers hold pool "
395
+ "channels permanently; retry/direct publish calls may exhaust "
396
+ "the remaining slots and deadlock. "
397
+ "Increase PoolConfig.channel_pool_size to at least flush_workers * 2.",
398
+ RuntimeWarning,
399
+ stacklevel=2,
400
+ )
401
+ self._batch_publisher = AsyncBatchPublisher(self._transport, batch_cfg)
402
+ await self._batch_publisher.start()
403
+
404
+ # Declare topology
405
+ await self._declare_topology()
406
+
407
+ # Install RetryMiddleware on retry-enabled routes (topology alone does
408
+ # not retry — the middleware routes failures into the delay queues).
409
+ self._wire_retry_middleware()
410
+
411
+ # Connection-churn counter: reconnects were logged but never counted.
412
+ self._wire_reconnect_metric()
413
+
414
+ # Create worker pool if requested
415
+ if worker_config is not None and worker_config.worker_count > 1:
416
+ # Warn if worker_count exceeds channel_pool_size — all workers
417
+ # publishing simultaneously will exhaust the channel pool and
418
+ # block until acquire_timeout, risking deadlock under load.
419
+ if worker_config.worker_count > self._config.pool.channel_pool_size:
420
+ import warnings
421
+
422
+ warnings.warn(
423
+ f"worker_count={worker_config.worker_count} exceeds "
424
+ f"channel_pool_size={self._config.pool.channel_pool_size}. "
425
+ "Concurrent publishes may exhaust the channel pool and deadlock. "
426
+ "Increase PoolConfig.channel_pool_size to at least worker_count.",
427
+ RuntimeWarning,
428
+ stacklevel=2,
429
+ )
430
+ # Override prefetch_count if prefetch_per_worker is set
431
+ if worker_config.prefetch_per_worker is not None:
432
+ self._consumer_config = replace(
433
+ self._config.consumer,
434
+ prefetch_count=worker_config.worker_count * worker_config.prefetch_per_worker,
435
+ )
436
+ self._worker_pool = AsyncWorkerPool(config=worker_config)
437
+ self._worker_pool.start()
438
+
439
+ # Start consuming
440
+ for route in self._registry.routes:
441
+ await self._start_consumer(route)
442
+
443
+ # L14: periodic liveness heartbeat -- keeps a healthy, message-idle
444
+ # consumer from going stale between deliveries. See _heartbeat_loop.
445
+ self._heartbeat_task = asyncio.ensure_future(self._heartbeat_loop())
446
+
447
+ self._started = True
448
+ logger.info(
449
+ "AsyncBroker started with %d routes",
450
+ len(self._registry.routes),
451
+ )
452
+
453
+ # H11: clear any shutdown request left over from a previous
454
+ # start()/stop() cycle so run() doesn't return immediately.
455
+ self._shutdown_event.clear()
456
+
457
+ if install_signal_handlers:
458
+ self._install_signal_handlers()
459
+
460
+ async def run(self, worker_config: WorkerConfig | None = None) -> None:
461
+ """Start, wait for a shutdown signal, then stop (H11).
462
+
463
+ ``await broker.start()`` alone installs signal handlers whose drain is
464
+ fire-and-forget — nothing joins the ``stop()`` task they create, so
465
+ whether in-flight messages actually finish draining depends on
466
+ incidental event-loop lifetime (e.g. it can be cut short by
467
+ ``asyncio.run()`` cancelling outstanding tasks once the awaited
468
+ coroutine returns). ``run()`` is the direct-use equivalent of
469
+ ``RabbitApp.run_async()``: it does not return until the drain
470
+ triggered by SIGINT/SIGTERM (or :meth:`request_shutdown`) has fully
471
+ completed, so awaiting it end-to-end guarantees in-flight messages are
472
+ settled before the process exits::
473
+
474
+ broker = AsyncBroker(config)
475
+
476
+ @broker.subscriber(queue="orders")
477
+ async def handle_order(body: bytes) -> None: ...
478
+
479
+ asyncio.run(broker.run())
480
+
481
+ Use plain ``start()``/``stop()`` instead when an outer lifecycle
482
+ manager (``RabbitApp``) owns the run loop.
483
+ """
484
+ await self.start(worker_config=worker_config)
485
+ self._run_waiting = True
486
+ try:
487
+ await self._shutdown_event.wait()
488
+ finally:
489
+ self._run_waiting = False
490
+ await self.stop()
491
+
492
+ def _install_signal_handlers(self) -> None:
493
+ """Install portable SIGINT/SIGTERM handlers that drain via stop() (H-SRE5).
494
+
495
+ Prefers ``loop.add_signal_handler``; falls back to ``signal.signal`` on
496
+ platforms/threads where the loop API is unavailable. Idempotent.
497
+ """
498
+ try:
499
+ loop = asyncio.get_running_loop()
500
+ except RuntimeError: # pragma: no cover
501
+ return # no running loop - nothing to install
502
+ self._loop = loop
503
+ installed = False
504
+ try:
505
+ for sig in (signal.SIGINT, signal.SIGTERM):
506
+ loop.add_signal_handler(sig, self._on_signal)
507
+ installed = True
508
+ except (NotImplementedError, RuntimeError, ValueError): # pragma: no cover
509
+ pass # pragma: no cover
510
+ self._installed_loop_handlers = installed
511
+ if not installed: # pragma: no cover
512
+ for sig in (signal.SIGINT, signal.SIGTERM): # pragma: no cover
513
+ try: # pragma: no cover
514
+ self._original_handlers[sig] = signal.signal(sig, self._on_signal_sync) # pragma: no cover
515
+ except (ValueError, OSError): # pragma: no cover - not main thread
516
+ pass
517
+
518
+ def _trigger_shutdown(self) -> None:
519
+ """Set the shutdown event so an in-progress ``run()`` joins the drain
520
+ (H11). If nothing is awaiting it via ``run()``, falls back to the
521
+ pre-H11 fire-and-forget ``stop()`` task so bare ``await
522
+ broker.start()`` usage still drains on signal.
523
+ """
524
+ self._shutdown_event.set()
525
+ if not self._run_waiting and self._loop is not None:
526
+ self._loop.create_task(self.stop())
527
+
528
+ def _on_signal(self) -> None:
529
+ logger.info("Received shutdown signal; initiating graceful drain")
530
+ self._trigger_shutdown()
531
+ if self.on_app_shutdown is not None:
532
+ try:
533
+ self.on_app_shutdown()
534
+ except Exception: # pragma: no cover - never block shutdown on the callback
535
+ logger.warning("on_app_shutdown callback raised", exc_info=True)
536
+
537
+ def _on_signal_sync(self, signum: int, frame: Any) -> None: # pragma: no cover
538
+ logger.info("Received shutdown signal %d", signum)
539
+ if self._loop is not None:
540
+ self._loop.call_soon_threadsafe(self._trigger_shutdown)
541
+ if self.on_app_shutdown is not None:
542
+ try:
543
+ self.on_app_shutdown()
544
+ except Exception: # pragma: no cover
545
+ logger.warning("on_app_shutdown callback raised", exc_info=True)
546
+
547
+ def request_shutdown(self) -> None:
548
+ """Request a graceful shutdown from any context — e.g. a failing
549
+ health check or a management command (H11). Equivalent to receiving
550
+ SIGINT/SIGTERM: if ``run()`` is awaiting shutdown it performs the
551
+ drain; otherwise this schedules a fire-and-forget ``stop()``.
552
+ """
553
+ self._trigger_shutdown()
554
+
555
+ def _restore_signal_handlers(self) -> None:
556
+ if self._loop is not None and self._installed_loop_handlers:
557
+ for sig in (signal.SIGINT, signal.SIGTERM):
558
+ try:
559
+ self._loop.remove_signal_handler(sig)
560
+ except (NotImplementedError, RuntimeError, ValueError): # pragma: no cover
561
+ pass
562
+ self._installed_loop_handlers = False
563
+ for sig, prev in self._original_handlers.items(): # pragma: no cover
564
+ try: # pragma: no cover
565
+ signal.signal(sig, prev) # pragma: no cover
566
+ except (ValueError, OSError): # pragma: no cover
567
+ pass # pragma: no cover
568
+ self._original_handlers.clear()
569
+
570
+ async def _wait_in_flight(self, deadline: float | None) -> None:
571
+ cond = self._ensure_inflight_cond()
572
+ async with cond:
573
+ if self._in_flight == 0:
574
+ return
575
+ while self._in_flight > 0:
576
+ if deadline is None:
577
+ await cond.wait()
578
+ continue
579
+ remaining = max(0.0, deadline - time.monotonic())
580
+ if remaining <= 0:
581
+ break
582
+ # R-timeout: use asyncio.timeout instead of asyncio.wait_for to
583
+ # avoid the wrapper-task overhead and let the wait be cancelled
584
+ # cleanly when the deadline expires.
585
+ try:
586
+ async with asyncio.timeout(remaining):
587
+ await cond.wait()
588
+ except TimeoutError:
589
+ break
590
+ # Deadline elapsed with handlers still running: cancel + nack them
591
+ # explicitly (delivery-tag logged) instead of silently abandoning
592
+ # them unacked -- matches AsyncWorkerPool.stop()'s behavior for the
593
+ # pooled path. Outside the `async with cond:` block since we're no
594
+ # longer touching `_in_flight`/the condition itself here, and
595
+ # cancelling a task can re-enter this broker (e.g. its `finally`
596
+ # decrementing `_in_flight`), which would deadlock re-acquiring cond.
597
+ if self._in_flight > 0:
598
+ logger.warning(
599
+ "AsyncBroker.stop: %d in-flight handler(s) still running after "
600
+ "graceful drain deadline; disconnecting anyway",
601
+ self._in_flight,
602
+ )
603
+ tasks = dict(self._inflight_tasks)
604
+ for task in tasks:
605
+ task.cancel()
606
+ if tasks:
607
+ await asyncio.gather(*tasks, return_exceptions=True)
608
+ for message in tasks.values():
609
+ if message.is_settled:
610
+ continue # handler reached its own ack/nack before being cut off
611
+ logger.warning(
612
+ "AsyncBroker.stop: handler for delivery_tag=%s message_id=%s did not "
613
+ "complete within graceful_timeout; abandoning (task cancelled) and "
614
+ "nacking for redelivery — ensure handlers are idempotent under "
615
+ "at-least-once delivery",
616
+ message.delivery_tag,
617
+ message.message_id,
618
+ )
619
+ try:
620
+ await message.nack_async(requeue=True)
621
+ except Exception:
622
+ logger.warning("nack on abandoned handler's message raised", exc_info=True)
623
+
624
+ async def stop(self, timeout: float | None = None) -> None:
625
+ """Stop the broker - cancel consumers, drain pool, drain in-flight, disconnect.
626
+
627
+ ``timeout`` defaults to ``ConsumerConfig.graceful_timeout`` (C-2). The
628
+ whole sequence is bounded by an overall deadline.
629
+
630
+ C5: consumers are cancelled FIRST, before the worker pool is drained.
631
+ Draining the pool before cancelling left the consumer active for the
632
+ entire (potentially graceful_timeout-long) drain wait — a message
633
+ delivered in that window was submitted via ``AsyncWorkerPool.submit()``,
634
+ which creates a task unconditionally (it never checks ``_running``) and
635
+ adds it to ``_tasks``. If that submit happens after ``.stop()`` already
636
+ cleared ``_tasks``, the new task is never awaited by anything — an
637
+ orphaned background task racing the event loop's shutdown, with the
638
+ message never cleanly settled before ``disconnect()``, so it is
639
+ redelivered (duplicate-processing risk) on the next connection.
640
+ Cancelling first stops new deliveries outright, so the pool only ever
641
+ drains work that was already in flight.
642
+ """
643
+ if not self._started:
644
+ return
645
+
646
+ self._restore_signal_handlers()
647
+
648
+ effective = timeout if timeout is not None else self._consumer_config.graceful_timeout
649
+ deadline = None if effective is None else time.monotonic() + effective
650
+
651
+ # L14: stop the periodic heartbeat first, alongside cancelling consumers.
652
+ if self._heartbeat_task is not None:
653
+ self._heartbeat_task.cancel()
654
+ with contextlib.suppress(asyncio.CancelledError):
655
+ await self._heartbeat_task
656
+ self._heartbeat_task = None
657
+
658
+ # Cancel all consumers FIRST — stop new deliveries before draining
659
+ # anything, so nothing new arrives while the pool/in-flight drain runs.
660
+ assert self._transport is not None
661
+ for route in self._registry.routes:
662
+ if route.consumer_tag:
663
+ await self._transport.cancel_consumer(route.consumer_tag)
664
+
665
+ # Drain the worker pool (let in-flight pooled tasks finish), bounded by
666
+ # the outer deadline.
667
+ if self._worker_pool is not None:
668
+ remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
669
+ await self._worker_pool.stop(timeout=remaining)
670
+ self._worker_pool = None
671
+
672
+ # Drain inline in-flight handlers (C-2).
673
+ await self._wait_in_flight(deadline)
674
+
675
+ # Stop batch publisher (drain remaining messages before disconnecting)
676
+ if self._batch_publisher is not None:
677
+ await self._batch_publisher.stop()
678
+ self._batch_publisher = None
679
+
680
+ # Close RPC client if used
681
+ if self._rpc_client is not None:
682
+ await self._rpc_client.close()
683
+ self._rpc_client = None
684
+
685
+ # Disconnect
686
+ if self._transport:
687
+ await self._transport.disconnect()
688
+
689
+ self._started = False
690
+ logger.info("AsyncBroker stopped")
691
+
692
+ # ── Publishing ────────────────────────────────────────────────────────
693
+
694
+ async def publish(
695
+ self,
696
+ envelope: MessageEnvelope | None = None,
697
+ *,
698
+ exchange: str = "",
699
+ routing_key: str = "",
700
+ body: bytes | str | dict[str, Any] | None = None,
701
+ headers: dict[str, Any] | None = None,
702
+ content_type: str = "application/json",
703
+ correlation_id: str | None = None,
704
+ reply_to: str | None = None,
705
+ ) -> PublishOutcome:
706
+ """Publish a message.
707
+
708
+ Accepts either a pre-built ``MessageEnvelope`` or individual kwargs::
709
+
710
+ # Envelope form (full control):
711
+ await broker.publish(MessageEnvelope(routing_key="orders.created", body=b"..."))
712
+
713
+ # Kwargs form (convenient):
714
+ await broker.publish(
715
+ exchange="orders",
716
+ routing_key="orders.created",
717
+ body={"order_id": 123},
718
+ headers={"x-tenant": "acme"},
719
+ )
720
+
721
+ When ``body`` is a dict or str it is JSON-encoded automatically.
722
+
723
+ When an opt-in ``FlowController`` is configured (``broker.flow_controller
724
+ = fc``), a publish slot is acquired/released around the transport publish
725
+ so backpressure/rate-limiting applies to the hot path. Without a
726
+ controller this is a plain pass-through.
727
+
728
+ When ``middlewares=[...]`` was passed to the constructor, each
729
+ middleware's ``publish_scope_async`` wraps this call (e.g.
730
+ ``SigningMiddleware`` signs the envelope) — see ``publish_middlewares``.
731
+ Middleware wraps OUTSIDE both flow control and batching, so a
732
+ middleware-transformed envelope is what gets rate-limited/batched, and
733
+ what actually reaches the wire.
734
+ """
735
+ if envelope is None:
736
+ import json as _json
737
+
738
+ if body is None:
739
+ raw_body = b""
740
+ elif isinstance(body, bytes):
741
+ raw_body = body
742
+ elif isinstance(body, str):
743
+ raw_body = body.encode()
744
+ else:
745
+ raw_body = _json.dumps(body).encode()
746
+ # M2: honor PublisherConfig defaults for the kwargs form (they were
747
+ # previously dead config). Envelope-form callers keep full control.
748
+ envelope = MessageEnvelope(
749
+ routing_key=routing_key,
750
+ body=raw_body,
751
+ exchange=exchange,
752
+ headers=headers or {},
753
+ content_type=content_type,
754
+ correlation_id=correlation_id,
755
+ reply_to=reply_to,
756
+ mandatory=self._config.publisher.mandatory,
757
+ delivery_mode=2 if self._config.publisher.persistent else 1,
758
+ )
759
+
760
+ # M10: reject oversized bodies at publish time (see sync broker).
761
+ max_bytes = self._config.publisher.max_message_bytes
762
+ if max_bytes and len(envelope.body) > max_bytes:
763
+ raise ValueError(
764
+ f"Message body ({len(envelope.body)} bytes) exceeds "
765
+ f"PublisherConfig.max_message_bytes ({max_bytes}). Large messages are a "
766
+ "RabbitMQ anti-pattern — store the payload externally and publish a "
767
+ "reference, or raise the limit if this is intentional."
768
+ )
769
+
770
+ if self._transport is None:
771
+ raise RuntimeError("Broker not started. Call start() first.")
772
+
773
+ publish_fn = (
774
+ self._batch_publisher.publish
775
+ if self._batch_publisher is not None
776
+ else self._transport.publish
777
+ )
778
+
779
+ async def do_publish(env: MessageEnvelope) -> PublishOutcome:
780
+ fc = self._flow_controller
781
+ if fc is not None:
782
+ if not await fc.acquire_async():
783
+ return PublishOutcome(
784
+ status=PublishStatus.ERROR,
785
+ exchange=env.exchange,
786
+ routing_key=env.routing_key,
787
+ error=BackpressureError("publish dropped by backpressure policy"),
788
+ )
789
+ try:
790
+ return await publish_fn(env) # type: ignore[no-any-return]
791
+ finally:
792
+ await fc.release_async()
793
+ return await publish_fn(env) # type: ignore[no-any-return]
794
+
795
+ if self._publish_middlewares:
796
+ chain = self._pipeline.compose_broker_publish_async(self._publish_middlewares)
797
+ outcome: PublishOutcome = await chain(envelope, do_publish)
798
+ return outcome
799
+ return await do_publish(envelope)
800
+
801
+ async def _flow_controlled_internal_publish(self, env: MessageEnvelope) -> PublishOutcome:
802
+ """M18: async mirror of ``SyncBroker._flow_controlled_internal_publish``
803
+ — see its docstring. Used as the ``publish_fn`` for ``RetryMiddleware``'s
804
+ delay-queue republish and the pipeline's result/RPC-reply publish so
805
+ the broker's ``FlowController`` (if configured) applies to these
806
+ internal publishes too. Never lets ``BackpressureError`` escape as an
807
+ exception — a blocked/dropped slot always resolves as a failed
808
+ ``PublishOutcome`` so existing nack+requeue handling applies
809
+ regardless of the configured ``on_blocked`` policy.
810
+ """
811
+ if self._transport is None: # pragma: no cover — defensive; callers only run while consuming
812
+ raise RuntimeError("Broker not started. Call start() first.")
813
+ transport = self._transport
814
+ fc = self._flow_controller
815
+ if fc is None:
816
+ return await transport.publish(env) # type: ignore[no-any-return]
817
+ try:
818
+ acquired = await fc.acquire_async()
819
+ except BackpressureError as exc:
820
+ return PublishOutcome(
821
+ status=PublishStatus.ERROR, exchange=env.exchange, routing_key=env.routing_key, error=exc
822
+ )
823
+ if not acquired:
824
+ return PublishOutcome(
825
+ status=PublishStatus.ERROR,
826
+ exchange=env.exchange,
827
+ routing_key=env.routing_key,
828
+ error=BackpressureError("publish dropped by backpressure policy"),
829
+ )
830
+ try:
831
+ return await transport.publish(env) # type: ignore[no-any-return]
832
+ finally:
833
+ await fc.release_async()
834
+
835
+ async def request(
836
+ self,
837
+ routing_key: str,
838
+ body: bytes,
839
+ *,
840
+ timeout: float = 5.0,
841
+ exchange: str = "",
842
+ headers: dict[str, Any] | None = None,
843
+ ) -> RabbitMessage:
844
+ """Send an RPC request and wait for a response.
845
+
846
+ Lazily initializes an AsyncRPCClient on first call.
847
+ The client is shared across calls and closed in stop().
848
+
849
+ Args:
850
+ routing_key: Target queue/routing key.
851
+ body: Request body as bytes.
852
+ timeout: Max seconds to wait for response.
853
+ exchange: Exchange to publish to (default "").
854
+ headers: Optional AMQP headers.
855
+
856
+ Returns:
857
+ RabbitMessage containing the response.
858
+
859
+ Raises:
860
+ RuntimeError: If broker is not started.
861
+ RPCTimeoutError: If no response within timeout.
862
+ """
863
+ if self._transport is None:
864
+ raise RuntimeError("Broker not started. Call start() first.")
865
+ if self._rpc_client is None:
866
+ from rabbitkit.rpc import AsyncRPCClient
867
+
868
+ self._rpc_client = AsyncRPCClient(self._transport)
869
+ return await self._rpc_client.call(routing_key, body, timeout=timeout, exchange=exchange, headers=headers)
870
+
871
+ # ── Internal ──────────────────────────────────────────────────────────
872
+
873
+ def _wire_retry_middleware(self) -> None:
874
+ """Install ``RetryMiddleware`` on routes whose retry is enabled.
875
+
876
+ ``_declare_topology`` declares the retry/DLQ *topology* (delay queues +
877
+ source-queue DLX), but the ``RetryMiddleware`` that actually routes a
878
+ failed message into the delay queues must also run in the route's
879
+ middleware chain. Without it, ``retry=RetryConfig(...)`` would build the
880
+ topology while transient failures ``nack(requeue=True)`` in a hot loop —
881
+ the delay queues would never receive anything and ``max_retries`` would
882
+ never be enforced. This wires both halves from the single retry switch
883
+ (``RabbitConfig.retry`` / ``@subscriber(retry=...)``).
884
+
885
+ Placed outer of ordinary user middlewares (e.g. ``TimeoutMiddleware``) so
886
+ retry can classify and re-queue exceptions they raise, but inner of any
887
+ ``ExceptionMiddleware`` (the documented true-outermost layer) — see
888
+ :func:`rabbitkit.middleware.retry.retry_middleware_insertion_index`.
889
+
890
+ Idempotent: routes that already carry a ``RetryMiddleware`` — supplied
891
+ explicitly via ``middlewares=[...]`` or auto-wired on a previous start —
892
+ are left untouched (no double-retry, no stacking on reconnect).
893
+ """
894
+ if self._transport is None:
895
+ return
896
+
897
+ from rabbitkit.middleware.metrics import MetricsMiddleware
898
+ from rabbitkit.middleware.retry import (
899
+ RetryMiddleware,
900
+ retry_middleware_insertion_index,
901
+ warn_retry_middleware_without_topology,
902
+ warn_retry_without_confirms,
903
+ )
904
+ from rabbitkit.middleware.signing import check_signing_retry_conflict
905
+
906
+ wired = False
907
+ for route in self._registry.routes:
908
+ retry_config = route.effective_retry_config(self._config.retry)
909
+ has_retry_mw = any(isinstance(mw, RetryMiddleware) for mw in route.route_middlewares)
910
+ if retry_config is None:
911
+ if has_retry_mw:
912
+ # Half-configured: a RetryMiddleware runs but no retry topology
913
+ # was declared, so its delay-queue publishes target non-existent
914
+ # queues and are silently dropped. Surface it loudly.
915
+ warn_retry_middleware_without_topology(route.name)
916
+ continue
917
+ # H1: signing + retry destroys every retried message — fail fast.
918
+ check_signing_retry_conflict(route.name, route.route_middlewares)
919
+ if has_retry_mw:
920
+ continue
921
+ if not self._config.publisher.confirm_delivery:
922
+ warn_retry_without_confirms(route.name) # M4
923
+ index = retry_middleware_insertion_index(route.route_middlewares)
924
+ # M2: if a MetricsMiddleware is already on this route, wire it into
925
+ # RetryMiddleware too so messages_retried_total/dead_lettered_total
926
+ # are observable (RetryMiddleware settles messages the pipeline
927
+ # itself never sees settle, so it must record these itself).
928
+ metrics_mw = next(
929
+ (mw for mw in route.route_middlewares if isinstance(mw, MetricsMiddleware)), None
930
+ )
931
+ route.route_middlewares.insert(
932
+ index,
933
+ RetryMiddleware(
934
+ retry_config,
935
+ publish_async_fn=self._flow_controlled_internal_publish, # M18
936
+ metrics_collector=metrics_mw.collector if metrics_mw else None,
937
+ metrics_config=metrics_mw.config if metrics_mw else None,
938
+ ),
939
+ )
940
+ wired = True
941
+
942
+ if not self._config.publisher.confirm_delivery:
943
+ for route in self._registry.routes:
944
+ if route.result_publisher is not None:
945
+ warn_retry_without_confirms(route.name, context="result") # M4
946
+
947
+ if wired:
948
+ # Drop any middleware chains cached before the retry mw was installed.
949
+ self._pipeline.clear_caches()
950
+
951
+ def _wire_reconnect_metric(self) -> None:
952
+ """Async mirror of ``SyncBroker._wire_reconnect_metric`` — count
953
+ transport reconnects (connection churn) via the first route
954
+ ``MetricsMiddleware``'s collector, if any. No-op without metrics."""
955
+ if self._transport is None:
956
+ return
957
+ from rabbitkit.middleware.metrics import MetricsMiddleware
958
+
959
+ metrics_mw = next(
960
+ (
961
+ mw
962
+ for route in self._registry.routes
963
+ for mw in route.route_middlewares
964
+ if isinstance(mw, MetricsMiddleware) and mw.collector is not None
965
+ ),
966
+ None,
967
+ )
968
+ if metrics_mw is None or metrics_mw.collector is None:
969
+ return
970
+ collector = metrics_mw.collector
971
+ metric_name = metrics_mw.config.reconnects_total
972
+ self._transport.on_reconnect(lambda: collector.inc_counter(metric_name, {}))
973
+
974
+ async def _declare_topology(self) -> None:
975
+ """Declare exchanges, queues, and bindings for all routes."""
976
+ if self._transport is None:
977
+ return
978
+
979
+ for route in self._registry.routes:
980
+ # Declare exchange
981
+ if route.exchange is not None:
982
+ await self._transport.declare_exchange(route.exchange)
983
+
984
+ # Exchange-to-exchange binding
985
+ bind_kwargs = route.exchange.to_bind_kwargs()
986
+ if bind_kwargs is not None:
987
+ await self._transport.bind_exchange(
988
+ destination=bind_kwargs["destination"],
989
+ source=bind_kwargs["source"],
990
+ routing_key=bind_kwargs["routing_key"],
991
+ arguments=bind_kwargs["arguments"],
992
+ )
993
+
994
+ # Determine retry config early so source queue can include DLQ routing
995
+ retry_config = route.effective_retry_config(self._config.retry)
996
+ # C3: a route with no dead-letter path can reject(requeue=False)
997
+ # (permanent errors, filter_fn, RejectMessage) and RabbitMQ would
998
+ # DISCARD the message. Apply SafetyConfig.reject_without_dlx:
999
+ # auto-provision "<queue>.dlq" (default), fail startup, or warn
1000
+ # and allow discard. Only under AUTO_DECLARE — in passive/manual
1001
+ # modes rabbitkit does not own the queue's arguments.
1002
+ safety_dlq_name: str | None = None
1003
+ if self._config.topology_mode is TopologyMode.AUTO_DECLARE:
1004
+ safety_dlq_name = route.resolve_safety_dlq(self._config.safety, self._config.retry)
1005
+ # A queue that IS another route's DLQ is terminal — consuming your
1006
+ # own DLQ is a legitimate pattern (inspect/replay consumers), and
1007
+ # auto-chaining more topology onto it (safety DLX injection, or
1008
+ # BROKER-DEFAULT retry inherited by the DLQ-consumer route) would
1009
+ # re-declare the DLQ with different arguments than the retry/
1010
+ # safety topology already declared it with — a 406 inequivalent-
1011
+ # arg startup failure, caught by the real-broker CI suite. An
1012
+ # EXPLICIT per-route retry= on a DLQ consumer still wins.
1013
+ is_anothers_dlq = any(
1014
+ other is not route and route.queue.name == f"{other.queue.name}.dlq"
1015
+ for other in self._registry.routes
1016
+ )
1017
+ if is_anothers_dlq:
1018
+ safety_dlq_name = None
1019
+ if route.retry_override is None:
1020
+ retry_config = None # don't inherit broker-default retry
1021
+
1022
+ if retry_config is not None:
1023
+ retry_router = RetryRouter(retry_config)
1024
+ dlq_name = retry_router.get_dlq_name(route.queue.name)
1025
+ # Re-declare source queue with x-dead-letter fields so RabbitMQ
1026
+ # automatically routes nacked/rejected messages to the DLQ.
1027
+ import dataclasses
1028
+
1029
+ source_queue = dataclasses.replace(
1030
+ route.queue,
1031
+ dead_letter_exchange="",
1032
+ dead_letter_routing_key=dlq_name,
1033
+ )
1034
+ elif safety_dlq_name is not None:
1035
+ import dataclasses
1036
+
1037
+ logger.info(
1038
+ "Auto-provisioned DLQ %r for queue %r (reject_without_dlx=auto_provision)",
1039
+ safety_dlq_name,
1040
+ route.queue.name,
1041
+ )
1042
+ source_queue = dataclasses.replace(
1043
+ route.queue,
1044
+ dead_letter_exchange="",
1045
+ dead_letter_routing_key=safety_dlq_name,
1046
+ )
1047
+ else:
1048
+ source_queue = route.queue
1049
+
1050
+ # Declare queue (with DLQ routing arguments if retry/safety DLX applies)
1051
+ await self._transport.declare_queue(source_queue)
1052
+
1053
+ # Bind queue to exchange (C4: bind_arguments matter for headers exchanges)
1054
+ if route.exchange is not None:
1055
+ await self._transport.bind_queue(
1056
+ queue=route.queue.name,
1057
+ exchange=route.exchange.name,
1058
+ routing_key=to_binding_key(route.queue.routing_key),
1059
+ arguments=route.queue.bind_arguments or None,
1060
+ )
1061
+
1062
+ # Declare retry/DLQ topology if retry is enabled
1063
+ if retry_config is not None:
1064
+ exchange_name = route.exchange.name if route.exchange else ""
1065
+ delay_queues = retry_router.get_delay_queue_definitions(route.queue.name, exchange_name)
1066
+ for delay_queue in delay_queues:
1067
+ await self._transport.declare_queue(delay_queue)
1068
+ elif safety_dlq_name is not None:
1069
+ await self._transport.declare_queue(RabbitQueue(name=safety_dlq_name, durable=True))
1070
+
1071
+ async def _start_consumer(self, route: RouteDefinition) -> None:
1072
+ """Start consuming for a single route."""
1073
+ if self._transport is None:
1074
+ return
1075
+
1076
+ pool = self._worker_pool
1077
+
1078
+ async def on_message(message: RabbitMessage) -> None:
1079
+ """Process incoming message through the pipeline."""
1080
+ # Track inline in-flight so stop() can drain gracefully (C-2).
1081
+ # Also register this task/message pair so a drain-deadline timeout
1082
+ # can cancel + nack it explicitly (with delivery-tag logging),
1083
+ # matching AsyncWorkerPool.stop()'s behavior for the pooled path,
1084
+ # instead of silently abandoning it unacked.
1085
+ await self._in_flight_inc()
1086
+ task = asyncio.current_task()
1087
+ if task is not None:
1088
+ self._inflight_tasks[task] = message
1089
+ self._mark_heartbeat()
1090
+ try:
1091
+ # Set the original queue in headers for retry routing
1092
+ if "x-rabbitkit-original-queue" not in message.headers:
1093
+ message.headers["x-rabbitkit-original-queue"] = route.queue.name
1094
+
1095
+ # Populate named routing-key segments for Path() DI
1096
+ message.path = extract_path(message.routing_key, route.queue.routing_key)
1097
+
1098
+ await self._pipeline.process_async(
1099
+ route,
1100
+ message,
1101
+ publish_fn=self._flow_controlled_internal_publish, # M18
1102
+ )
1103
+ finally:
1104
+ if task is not None:
1105
+ self._inflight_tasks.pop(task, None)
1106
+ await self._in_flight_dec()
1107
+
1108
+ if pool is not None:
1109
+
1110
+ async def on_message_pooled(message: RabbitMessage) -> None:
1111
+ await pool.submit(on_message, message)
1112
+
1113
+ callback = on_message_pooled
1114
+ else:
1115
+ callback = on_message
1116
+
1117
+ effective_prefetch = route.prefetch_count or self._consumer_config.prefetch_count
1118
+ consumer_tag = await self._transport.consume(
1119
+ queue=route.queue.name,
1120
+ callback=callback,
1121
+ prefetch=effective_prefetch,
1122
+ )
1123
+ route.runtime_state.consumer_tag = consumer_tag