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,1289 @@
1
+ """Handler invocation pipeline — orchestrates message processing.
2
+
3
+ Executes the full message processing pipeline:
4
+ - See Contract 3 (Middleware Ordering) for exact chain.
5
+ - See Contract 4 (Parameter Resolution) for DI rules.
6
+ - See Contract 5 (Result Publishing) for publish precedence.
7
+ - See Contract 1 (AckPolicy) for ack behavior.
8
+
9
+ Pipeline calls msg.ack() or await msg.ack_async() depending on transport type.
10
+ Decompression operates on message.body before deserialize.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import logging
17
+ from collections.abc import Awaitable, Callable
18
+ from typing import Any, Protocol
19
+
20
+ import structlog
21
+
22
+ from rabbitkit.core.errors import classify_error
23
+ from rabbitkit.core.message import AckMessage, NackMessage, RabbitMessage, RejectMessage, is_rabbit_message_annotation
24
+ from rabbitkit.core.route import RouteDefinition
25
+ from rabbitkit.core.types import (
26
+ REQUEUED_FOR_RETRY,
27
+ AckPolicy,
28
+ AckStrategy,
29
+ ErrorSeverity,
30
+ MessageEnvelope,
31
+ PublishOutcome,
32
+ )
33
+ from rabbitkit.di.context import ContextRepo
34
+ from rabbitkit.di.resolver import DependencyScope, DIResolver
35
+ from rabbitkit.serialization.base import Serializer
36
+
37
+ logger = structlog.stdlib.get_logger(__name__)
38
+ _stdlib_logger = logging.getLogger(__name__)
39
+
40
+ # M10: on the async path, bodies at/above this size are decoded in a worker
41
+ # thread (asyncio.to_thread) so a large JSON/msgspec/pydantic parse doesn't
42
+ # block the event loop. Below it, inline decode is faster than the thread hop.
43
+ # ponytail: fixed 256 KiB threshold — make it configurable only if a workload
44
+ # proves the cutoff wrong.
45
+ _DECODE_OFFLOAD_THRESHOLD_BYTES = 256 * 1024
46
+
47
+
48
+ def _emit_settlement_metric(route: RouteDefinition, message: RabbitMessage) -> None:
49
+ """Emit the ack/nack/reject counter for *message*, if a
50
+ ``MetricsMiddleware`` is present on *route* (M2).
51
+
52
+ ``MetricsMiddleware.consume_scope``/``consume_scope_async`` only wrap
53
+ handler execution; final settlement is decided by this pipeline's own
54
+ ack-orchestration code, which runs AFTER that wrapped call returns —
55
+ so the middleware itself can never observe the final disposition, and
56
+ ``messages_acked_total``/``nacked_total``/``rejected_total`` would
57
+ otherwise be defined but never emitted. A local, lazy import is used
58
+ (not a module-level one) so ``core/`` does not gain a hard dependency
59
+ on ``middleware/`` — this is the one place core reaches up to an
60
+ optional, purely-observational integration, and only when a
61
+ ``MetricsMiddleware`` is actually configured on the route.
62
+
63
+ A "pending" disposition (e.g. a MANUAL-policy handler that hasn't
64
+ settled its message yet by the time this runs) emits nothing — there is
65
+ nothing final to report yet.
66
+ """
67
+ if message.disposition == "pending":
68
+ return
69
+ from rabbitkit.middleware.metrics import MetricsMiddleware
70
+
71
+ for mw in route.route_middlewares:
72
+ if isinstance(mw, MetricsMiddleware):
73
+ mw.record_settlement(message, message.disposition)
74
+ return
75
+
76
+
77
+ def _log_result_publish_failure(message: RabbitMessage, outcome: PublishOutcome) -> None:
78
+ """Log a failed result publish (L1).
79
+
80
+ The caller (``_publish_result_sync``/``_publish_result_async``) nacks
81
+ the source message with ``requeue=True`` on failure, re-running the
82
+ handler (including any side effects) on redelivery. ``message.redelivered``
83
+ is ``True`` when THIS delivery is itself already a redelivery — logging
84
+ at ERROR (vs. WARNING on a first attempt) makes a sustained publish
85
+ outage, which would otherwise hot-loop this nack+requeue silently,
86
+ loud and alertable via log-based monitoring instead of only a stream
87
+ of routine-looking WARNINGs.
88
+ """
89
+ log = logger.error if message.redelivered else logger.warning
90
+ log(
91
+ "Result publish failed%s: status=%s, exchange=%s, routing_key=%s",
92
+ " (message already redelivered once -- this nack+requeue is repeating; "
93
+ "verify broker health and that the handler is idempotent under repeated "
94
+ "execution)"
95
+ if message.redelivered
96
+ else "",
97
+ outcome.status,
98
+ outcome.exchange,
99
+ outcome.routing_key,
100
+ )
101
+
102
+
103
+ # ── AckPolicy strategy dispatch ──────────────────────────────────────────
104
+ # Replaces the per-call if/elif chains over AckPolicy with a single dict
105
+ # lookup. Each strategy owns the success-path ack and the error-path
106
+ # settlement; handler-raised AckMessage/NackMessage/RejectMessage stay in
107
+ # the pipeline (they are handler-driven, not policy-driven).
108
+
109
+
110
+ class _AutoStrategy:
111
+ """AUTO: success→ack, exception→classify→nack(requeue)/reject."""
112
+
113
+ acks_first: bool = False
114
+
115
+ def on_success(self, msg: RabbitMessage) -> None:
116
+ if not msg.is_settled:
117
+ msg.ack()
118
+
119
+ def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
120
+ classified = classify_error(exc)
121
+ if classified.severity == ErrorSeverity.TRANSIENT:
122
+ msg.nack(requeue=True)
123
+ else:
124
+ msg.reject(requeue=False)
125
+
126
+
127
+ class _ManualStrategy:
128
+ """MANUAL: handler owns settlement ENTIRELY; pipeline never auto-settles on success.
129
+
130
+ M11: ``on_success`` previously did ``if not msg.is_settled: msg.ack()``
131
+ — contradicting this class's own documented "handler owns settlement"
132
+ contract. A MANUAL handler that intentionally defers settlement (e.g.
133
+ hands the message to another task/thread to ack later) got an
134
+ unexpected ack right here — a real loss risk: if the process crashes
135
+ before that deferred settlement actually runs, the message is gone
136
+ (already acked) instead of being redelivered.
137
+ """
138
+
139
+ acks_first: bool = False
140
+
141
+ def on_success(self, msg: RabbitMessage) -> None:
142
+ if not msg.is_settled:
143
+ logger.warning(
144
+ "MANUAL ack policy: handler returned without settling the message "
145
+ "(no ack()/nack()/reject() called) — left unsettled, not auto-acked. "
146
+ "If settlement is deferred intentionally, ignore this; otherwise the "
147
+ "handler is missing a settlement call."
148
+ )
149
+
150
+ def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
151
+ logger.error("Unhandled exception in MANUAL mode handler: %s", exc)
152
+ raise
153
+
154
+
155
+ class _NackOnErrorStrategy:
156
+ """NACK_ON_ERROR: success→ack, exception→nack(requeue=False)."""
157
+
158
+ acks_first: bool = False
159
+
160
+ def on_success(self, msg: RabbitMessage) -> None:
161
+ if not msg.is_settled:
162
+ msg.ack()
163
+
164
+ def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
165
+ msg.nack(requeue=False)
166
+
167
+
168
+ class _AckFirstStrategy:
169
+ """ACK_FIRST: ack BEFORE the handler runs (at-most-once)."""
170
+
171
+ acks_first: bool = True
172
+
173
+ def on_success(self, msg: RabbitMessage) -> None:
174
+ if not msg.is_settled:
175
+ msg.ack()
176
+
177
+ def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
178
+ # Unreachable in practice — the message is pre-acked before the handler
179
+ # runs, so _handle_*_exception returns early on is_settled. Classify
180
+ # like AUTO for defensiveness if ever called on an unsettled message.
181
+ classified = classify_error(exc)
182
+ if classified.severity == ErrorSeverity.TRANSIENT:
183
+ msg.nack(requeue=True)
184
+ else:
185
+ msg.reject(requeue=False)
186
+
187
+
188
+ _ACK_STRATEGIES: dict[AckPolicy, AckStrategy] = {
189
+ AckPolicy.AUTO: _AutoStrategy(),
190
+ AckPolicy.MANUAL: _ManualStrategy(),
191
+ AckPolicy.NACK_ON_ERROR: _NackOnErrorStrategy(),
192
+ AckPolicy.ACK_FIRST: _AckFirstStrategy(),
193
+ }
194
+
195
+
196
+ class _AsyncAckStrategy(Protocol):
197
+ """Async counterpart of ``AckStrategy`` for the async pipeline."""
198
+
199
+ @property
200
+ def acks_first(self) -> bool: ...
201
+
202
+ async def on_success(self, msg: RabbitMessage) -> None: ...
203
+
204
+ async def on_error(self, msg: RabbitMessage, exc: Exception) -> None: ...
205
+
206
+
207
+ class _AutoStrategyAsync:
208
+ acks_first: bool = False
209
+
210
+ async def on_success(self, msg: RabbitMessage) -> None:
211
+ if not msg.is_settled:
212
+ await msg.ack_async()
213
+
214
+ async def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
215
+ classified = classify_error(exc)
216
+ if classified.severity == ErrorSeverity.TRANSIENT:
217
+ await msg.nack_async(requeue=True)
218
+ else:
219
+ await msg.reject_async(requeue=False)
220
+
221
+
222
+ class _ManualStrategyAsync:
223
+ """MANUAL: handler owns settlement ENTIRELY (M11 — see ``_ManualStrategy``'s
224
+ docstring for why ``on_success`` must not auto-ack)."""
225
+
226
+ acks_first: bool = False
227
+
228
+ async def on_success(self, msg: RabbitMessage) -> None:
229
+ if not msg.is_settled:
230
+ logger.warning(
231
+ "MANUAL ack policy: handler returned without settling the message "
232
+ "(no ack()/nack()/reject() called) — left unsettled, not auto-acked. "
233
+ "If settlement is deferred intentionally, ignore this; otherwise the "
234
+ "handler is missing a settlement call."
235
+ )
236
+
237
+ async def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
238
+ logger.error("Unhandled exception in MANUAL mode handler: %s", exc)
239
+ raise
240
+
241
+
242
+ class _NackOnErrorStrategyAsync:
243
+ acks_first: bool = False
244
+
245
+ async def on_success(self, msg: RabbitMessage) -> None:
246
+ if not msg.is_settled:
247
+ await msg.ack_async()
248
+
249
+ async def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
250
+ await msg.nack_async(requeue=False)
251
+
252
+
253
+ class _AckFirstStrategyAsync:
254
+ acks_first: bool = True
255
+
256
+ async def on_success(self, msg: RabbitMessage) -> None:
257
+ if not msg.is_settled:
258
+ await msg.ack_async()
259
+
260
+ async def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
261
+ classified = classify_error(exc)
262
+ if classified.severity == ErrorSeverity.TRANSIENT:
263
+ await msg.nack_async(requeue=True)
264
+ else:
265
+ await msg.reject_async(requeue=False)
266
+
267
+
268
+ _ACK_STRATEGIES_ASYNC: dict[AckPolicy, _AsyncAckStrategy] = {
269
+ AckPolicy.AUTO: _AutoStrategyAsync(),
270
+ AckPolicy.MANUAL: _ManualStrategyAsync(),
271
+ AckPolicy.NACK_ON_ERROR: _NackOnErrorStrategyAsync(),
272
+ AckPolicy.ACK_FIRST: _AckFirstStrategyAsync(),
273
+ }
274
+
275
+
276
+ class HandlerPipeline:
277
+ """Executes the full message processing pipeline.
278
+
279
+ on_receive() receives a RabbitMessage (transport builds it first).
280
+
281
+ The pipeline is responsible for:
282
+ 1. Ack timing (per AckPolicy)
283
+ 2. Deserialization (via serializer)
284
+ 3. Parameter resolution (via DI resolver)
285
+ 4. Handler invocation
286
+ 5. Result serialization and publishing
287
+ 6. Settlement (ack/nack/reject)
288
+
289
+ Both sync and async variants are provided.
290
+ """
291
+
292
+ def __init__(
293
+ self,
294
+ serializer: Serializer[Any] | None = None,
295
+ di_resolver: DIResolver | None = None,
296
+ context_repo: ContextRepo | None = None,
297
+ reject_transient_on_redelivery: bool = False,
298
+ ) -> None:
299
+ self._serializer = serializer
300
+ self._di_resolver = di_resolver
301
+ self._context_repo = context_repo
302
+ # M6: opt-in 2-strike cap on transient hot-loops (see ConsumerConfig).
303
+ self._reject_transient_on_redelivery = reject_transient_on_redelivery
304
+ # Per-handler caches so the hot path avoids inspect.signature() per message.
305
+ self._body_type_cache: dict[Any, type | None] = {}
306
+ self._sig_cache: dict[Any, Any] = {} # handler -> inspect.Signature (fallback resolver)
307
+ # Auto-DI: when no resolver is passed, handlers that use Depends/Header/Path/
308
+ # Context markers get a lazily-created resolver; marker-free handlers keep the
309
+ # fast fallback (so the simple-handler hot path and its behavior are unchanged).
310
+ self._auto_resolver: Any | None = None
311
+ self._needs_di_cache: dict[Any, bool] = {}
312
+ # P3: cache serializer.decode availability per serializer.
313
+ self._has_decode_cache: dict[Any, bool] = {}
314
+ # P4: precompute parameter binding plan per handler.
315
+ self._binding_plan_cache: dict[Any, list[tuple[str, str]]] = {}
316
+ # P5: cache whether handler is a coroutine function.
317
+ self._is_async_handler_cache: dict[Any, bool] = {}
318
+ # M-P1: cache the composed middleware chain per route — the chain depends
319
+ # only on route.route_middlewares (fixed after registration), so rebuild
320
+ # once per route instead of allocating N closures per message.
321
+ self._consume_chain_cache: dict[int, Callable[[RabbitMessage], Any]] = {}
322
+ self._consume_chain_async_cache: dict[int, Callable[[RabbitMessage], Awaitable[Any]]] = {}
323
+ self._publish_chain_cache: dict[
324
+ int, Callable[[MessageEnvelope, Callable[[MessageEnvelope], PublishOutcome]], Any]
325
+ ] = {}
326
+ self._publish_chain_async_cache: dict[
327
+ int, Callable[[MessageEnvelope, Callable[[MessageEnvelope], Awaitable[PublishOutcome]]], Awaitable[Any]]
328
+ ] = {}
329
+ # C3: broker-level publish middleware chains — keyed by id(middlewares),
330
+ # the SAME list object a broker stores once at construction (see
331
+ # compose_broker_publish_sync/_async). Separate from the route-keyed
332
+ # caches above because broker.publish() is not route-scoped.
333
+ self._broker_publish_chain_cache: dict[
334
+ int, Callable[[MessageEnvelope, Callable[[MessageEnvelope], PublishOutcome]], Any]
335
+ ] = {}
336
+ self._broker_publish_chain_async_cache: dict[
337
+ int, Callable[[MessageEnvelope, Callable[[MessageEnvelope], Awaitable[PublishOutcome]]], Awaitable[Any]]
338
+ ] = {}
339
+
340
+ def clear_caches(self) -> None:
341
+ """Drop all per-route caches.
342
+
343
+ Clears the four route-keyed middleware-chain caches
344
+ (``_consume_chain_cache``, ``_consume_chain_async_cache``,
345
+ ``_publish_chain_cache``, ``_publish_chain_async_cache``).
346
+
347
+ The caches are keyed by ``id(route)`` and are bounded by the number of
348
+ registered routes, which is typically small and stable. They are only
349
+ an eviction concern across reconnect/restart cycles where old
350
+ ``RouteDefinition`` objects are dropped and replaced by new ones; in
351
+ that case the stale entries (keyed by the old ``id``) would otherwise
352
+ linger. Call this on reconnect/restart to reclaim them — the next
353
+ message rebuilds the chain lazily.
354
+
355
+ Does NOT clear the broker-level publish chain caches
356
+ (``_broker_publish_chain_cache`` / ``_broker_publish_chain_async_cache``)
357
+ — those are keyed by the broker's ``publish_middlewares`` list, which is
358
+ set once at construction and never mutated in place, so they cannot go
359
+ stale the way route-keyed caches can.
360
+ """
361
+ self._consume_chain_cache.clear()
362
+ self._consume_chain_async_cache.clear()
363
+ self._publish_chain_cache.clear()
364
+ self._publish_chain_async_cache.clear()
365
+
366
+ def compose_broker_publish_sync(
367
+ self,
368
+ middlewares: list[Any],
369
+ ) -> Callable[[MessageEnvelope, Callable[[MessageEnvelope], PublishOutcome]], Any]:
370
+ """Compose broker-level ``publish_scope`` middlewares into a reusable chain.
371
+
372
+ Unlike :meth:`_compose_publish_sync` (which wraps a route's
373
+ HANDLER-RESULT publishes per Contract 5), this wraps ``broker.publish()``
374
+ itself — the primary producer API — so middleware such as signing or
375
+ tracing actually applies to direct publishes, not just replies/results.
376
+
377
+ Cached by ``id(middlewares)``: callers must pass the SAME list object on
378
+ every call (e.g. a broker stores it once at construction) for the cache
379
+ to hit — see :meth:`clear_caches`.
380
+ """
381
+ cached = self._broker_publish_chain_cache.get(id(middlewares))
382
+ if cached is not None:
383
+ return cached
384
+
385
+ def leaf(env: MessageEnvelope, fn: Callable[[MessageEnvelope], PublishOutcome]) -> Any:
386
+ return fn(env)
387
+
388
+ chain: Callable[[MessageEnvelope, Callable[[MessageEnvelope], PublishOutcome]], Any] = leaf
389
+ for mw in reversed(middlewares):
390
+ nxt = chain
391
+
392
+ def wrapped(
393
+ env: MessageEnvelope,
394
+ fn: Callable[[MessageEnvelope], PublishOutcome],
395
+ _mw: Any = mw,
396
+ _nxt: Any = nxt,
397
+ ) -> Any:
398
+ return _mw.publish_scope(lambda e: _nxt(e, fn), env)
399
+
400
+ chain = wrapped
401
+ self._broker_publish_chain_cache[id(middlewares)] = chain
402
+ return chain
403
+
404
+ def compose_broker_publish_async(
405
+ self,
406
+ middlewares: list[Any],
407
+ ) -> Callable[[MessageEnvelope, Callable[[MessageEnvelope], Awaitable[PublishOutcome]]], Awaitable[Any]]:
408
+ """Async variant of :meth:`compose_broker_publish_sync`."""
409
+ cached = self._broker_publish_chain_async_cache.get(id(middlewares))
410
+ if cached is not None:
411
+ return cached
412
+
413
+ async def leaf(
414
+ env: MessageEnvelope,
415
+ fn: Callable[[MessageEnvelope], Awaitable[PublishOutcome]],
416
+ ) -> Any:
417
+ return await fn(env)
418
+
419
+ chain: Callable[[MessageEnvelope, Callable[[MessageEnvelope], Awaitable[PublishOutcome]]], Awaitable[Any]] = (
420
+ leaf
421
+ )
422
+ for mw in reversed(middlewares):
423
+ nxt = chain
424
+
425
+ async def wrapped(
426
+ env: MessageEnvelope,
427
+ fn: Callable[[MessageEnvelope], Awaitable[PublishOutcome]],
428
+ _mw: Any = mw,
429
+ _nxt: Any = nxt,
430
+ ) -> Any:
431
+ return await _mw.publish_scope_async(lambda e: _nxt(e, fn), env)
432
+
433
+ chain = wrapped
434
+ self._broker_publish_chain_async_cache[id(middlewares)] = chain
435
+ return chain
436
+
437
+ def process_sync(
438
+ self,
439
+ route: RouteDefinition,
440
+ message: RabbitMessage,
441
+ publish_fn: Callable[[MessageEnvelope], PublishOutcome] | None = None,
442
+ ) -> None:
443
+ """Sync pipeline — calls msg.ack(), handler(), publish().
444
+
445
+ Pipeline stages:
446
+ 1. ACK_FIRST: ack before handler
447
+ 2. Deserialize body → resolve params → call handler
448
+ 3. Process result (serialize + publish if applicable)
449
+ 4. Settle message (ack/nack/reject per AckPolicy)
450
+ """
451
+ # Filter check — reject before any processing
452
+ if route.filter_fn is not None and not route.filter_fn(message):
453
+ if not message.is_settled:
454
+ message.nack(requeue=False)
455
+ _emit_settlement_metric(route, message)
456
+ return
457
+
458
+ # M-P3: only bind contextvars when DEBUG is emitted — avoids per-message
459
+ # dict/token churn on the hot path when structured logging isn't in DEBUG.
460
+ debug = _stdlib_logger.isEnabledFor(logging.DEBUG)
461
+ if debug:
462
+ structlog.contextvars.bind_contextvars(
463
+ message_id=message.message_id,
464
+ routing_key=message.routing_key,
465
+ queue=route.queue.name,
466
+ handler=getattr(route.handler, "__qualname__", repr(route.handler)),
467
+ )
468
+
469
+ try:
470
+ strategy = _ACK_STRATEGIES[route.ack_policy]
471
+
472
+ # ACK_FIRST: ack before handler runs
473
+ if strategy.acks_first:
474
+ message.ack()
475
+
476
+ try:
477
+ # Resolve parameters and call handler (through the middleware chain)
478
+ result = self._run_consume_sync(route, message)
479
+
480
+ # Publish result if needed (Contract 5). M7: the
481
+ # REQUEUED_FOR_RETRY sentinel is NOT a handler return value —
482
+ # an inner RetryMiddleware requeued the message and already
483
+ # settled it. Publishing it would serialize the sentinel as a
484
+ # bogus RPC reply/result (once per retry attempt). Skip it.
485
+ if (
486
+ result is not None
487
+ and result is not REQUEUED_FOR_RETRY
488
+ and not self._publish_result_sync(route, message, result, publish_fn)
489
+ ):
490
+ # Result lost — don't ack. Nack+requeue for redelivery
491
+ # (handlers are idempotent under at-least-once delivery).
492
+ if not message.is_settled:
493
+ message.nack(requeue=True)
494
+ return
495
+
496
+ # Settle on success
497
+ strategy.on_success(message)
498
+
499
+ except AckMessage:
500
+ if not message.is_settled:
501
+ message.ack()
502
+
503
+ except NackMessage as exc:
504
+ if not message.is_settled:
505
+ message.nack(requeue=exc.requeue)
506
+
507
+ except RejectMessage as exc:
508
+ if not message.is_settled:
509
+ message.reject(requeue=exc.requeue)
510
+
511
+ except Exception as exc:
512
+ self._handle_sync_exception(route, message, exc)
513
+
514
+ finally:
515
+ _emit_settlement_metric(route, message)
516
+ if debug:
517
+ structlog.contextvars.clear_contextvars()
518
+
519
+ async def process_async(
520
+ self,
521
+ route: RouteDefinition,
522
+ message: RabbitMessage,
523
+ publish_fn: Callable[[MessageEnvelope], Awaitable[PublishOutcome]] | None = None,
524
+ ) -> None:
525
+ """Async pipeline — calls await msg.ack_async(), await handler(), await publish().
526
+
527
+ Same stages as sync, but async.
528
+ """
529
+ # Filter check — reject before any processing
530
+ if route.filter_fn is not None and not route.filter_fn(message):
531
+ if not message.is_settled:
532
+ await message.nack_async(requeue=False)
533
+ _emit_settlement_metric(route, message)
534
+ return
535
+
536
+ # M-P3: only bind contextvars when DEBUG is emitted.
537
+ debug = _stdlib_logger.isEnabledFor(logging.DEBUG)
538
+ if debug:
539
+ structlog.contextvars.bind_contextvars(
540
+ message_id=message.message_id,
541
+ routing_key=message.routing_key,
542
+ queue=route.queue.name,
543
+ handler=getattr(route.handler, "__qualname__", repr(route.handler)),
544
+ )
545
+
546
+ try:
547
+ strategy = _ACK_STRATEGIES_ASYNC[route.ack_policy]
548
+
549
+ # ACK_FIRST: ack before handler runs
550
+ if strategy.acks_first:
551
+ await message.ack_async()
552
+
553
+ try:
554
+ # Resolve parameters and call handler (through the middleware chain)
555
+ result = await self._run_consume_async(route, message)
556
+
557
+ # Publish result if needed (Contract 5). M7: skip the
558
+ # REQUEUED_FOR_RETRY sentinel (see the sync path above).
559
+ if (
560
+ result is not None
561
+ and result is not REQUEUED_FOR_RETRY
562
+ and not await self._publish_result_async(route, message, result, publish_fn)
563
+ ):
564
+ # Result lost — don't ack. Nack+requeue for redelivery
565
+ # (handlers are idempotent under at-least-once delivery).
566
+ if not message.is_settled:
567
+ await message.nack_async(requeue=True)
568
+ return
569
+
570
+ # Settle on success
571
+ await strategy.on_success(message)
572
+
573
+ except AckMessage:
574
+ if not message.is_settled:
575
+ await message.ack_async()
576
+
577
+ except NackMessage as exc:
578
+ if not message.is_settled:
579
+ await message.nack_async(requeue=exc.requeue)
580
+
581
+ except RejectMessage as exc:
582
+ if not message.is_settled:
583
+ await message.reject_async(requeue=exc.requeue)
584
+
585
+ except Exception as exc:
586
+ await self._handle_async_exception(route, message, exc)
587
+
588
+ finally:
589
+ _emit_settlement_metric(route, message)
590
+ if debug:
591
+ structlog.contextvars.clear_contextvars()
592
+
593
+ # ── Internal: middleware composition ─────────────────────────────────
594
+
595
+ def _run_consume_sync(self, route: RouteDefinition, message: RabbitMessage) -> Any:
596
+ """Run on_receive hooks, then the consume_scope chain around the handler.
597
+
598
+ Middlewares are applied OUTER → INNER: the first item in
599
+ ``route.route_middlewares`` is the outermost wrapper. Each middleware's
600
+ ``consume_scope(call_next, message)`` wraps the next; the innermost
601
+ ``call_next`` deserializes + resolves + invokes the handler.
602
+
603
+ H7 — on_receive ordering and exception semantics (READ THIS before
604
+ combining SigningMiddleware/CompressionMiddleware or writing your own
605
+ on_receive-based transform):
606
+
607
+ * on_receive hooks run in a FIXED, FLAT pre-pass — entirely BEFORE the
608
+ consume_scope chain is entered. An exception raised here is NEVER
609
+ seen by any middleware's consume_scope (RetryMiddleware included):
610
+ it propagates straight to process_sync's own exception handler,
611
+ which settles the message per the route's AckPolicy using the
612
+ pipeline's default classifier — NOT RetryMiddleware's classifier
613
+ or predicates, and NEVER via RetryMiddleware's delay-queue routing.
614
+ A signing/decompression failure is not retried; it typically
615
+ rejects straight to the DLQ. This is deliberate (an on_receive
616
+ failure means "this delivery is untrustworthy or unreadable," not
617
+ "the handler failed" — retrying doesn't make a bad signature or a
618
+ corrupt payload become valid) and unlikely to change; if you need a
619
+ retry-eligible on_receive-style check, put it in ``consume_scope``
620
+ instead, where it participates in the same chain as everything else.
621
+ * on_receive hooks run in REVERSE registration order — deliberately
622
+ the mirror of publish_scope's OUTER→INNER composition order, so a
623
+ receive-side "undo" (e.g. decompress) always runs relative to a
624
+ publish-side "apply" (e.g. compress) in the mathematically correct
625
+ order regardless of what's paired with what. Concretely: for
626
+ ``middlewares=[A, B]``, publish applies A's transform then B's (A
627
+ outer, B inner); on_receive runs B's hook then A's — the reverse —
628
+ so whichever transform was applied LAST on publish is undone FIRST
629
+ on receive. Before this fix, on_receive ran in the SAME (forward)
630
+ order as publish_scope's apply order, so a receive-side hook always
631
+ ran against a body/metadata state that had already been
632
+ (or not yet been) transformed by the OTHER middleware — never
633
+ matching what that middleware actually needs.
634
+ * This fix does NOT make ``middlewares=[SigningMiddleware,
635
+ CompressionMiddleware]`` order-independent — only ONE relative
636
+ order works: ``middlewares=[CompressionMiddleware,
637
+ SigningMiddleware]`` (compression outer, signing inner). Reason:
638
+ SigningMiddleware's signature covers ``content_encoding`` (H3),
639
+ which is a field CompressionMiddleware's ``publish_scope`` is what
640
+ actually *sets*. If signing runs first (outer), it necessarily
641
+ signs ``content_encoding=None`` (unset at that point) while
642
+ compression sets it to e.g. ``"gzip"`` afterward — the delivered
643
+ message's ``content_encoding`` then never matches what was signed,
644
+ and verification fails unconditionally, independent of the
645
+ on_receive reordering above. Compression outer / signing inner is
646
+ the only order where signing sees the FINAL ``content_encoding``
647
+ it needs to sign correctly. See
648
+ ``tests/unit/core/test_pipeline_middleware.py::TestSigningCompressionComposition``
649
+ for a worked example of both the working and the failing order.
650
+ """
651
+ middlewares = route.route_middlewares
652
+ if not middlewares:
653
+ return self._invoke_handler_sync(route, message)
654
+
655
+ for mw in reversed(middlewares):
656
+ mw.on_receive(message)
657
+
658
+ # M-P1: cache the composed chain per route — closures are allocated once
659
+ # per route, not per message.
660
+ chain = self._consume_chain_cache.get(id(route))
661
+ if chain is None:
662
+
663
+ def call_next(msg: RabbitMessage) -> Any:
664
+ return self._invoke_handler_sync(route, msg)
665
+
666
+ for mw in reversed(middlewares):
667
+ nxt = call_next
668
+
669
+ def wrapped(msg: RabbitMessage, _mw: Any = mw, _nxt: Any = nxt) -> Any:
670
+ return _mw.consume_scope(_nxt, msg)
671
+
672
+ call_next = wrapped
673
+
674
+ chain = call_next
675
+ self._consume_chain_cache[id(route)] = chain
676
+
677
+ return chain(message)
678
+
679
+ async def _run_consume_async(self, route: RouteDefinition, message: RabbitMessage) -> Any:
680
+ """Async variant of :meth:`_run_consume_sync` — see its docstring
681
+ (H7) for on_receive's ordering and exception-interception semantics,
682
+ which apply identically here."""
683
+ middlewares = route.route_middlewares
684
+ if not middlewares:
685
+ return await self._invoke_handler_async(route, message)
686
+
687
+ for mw in reversed(middlewares):
688
+ await mw.on_receive_async(message)
689
+
690
+ chain = self._consume_chain_async_cache.get(id(route))
691
+ if chain is None:
692
+
693
+ async def call_next(msg: RabbitMessage) -> Any:
694
+ return await self._invoke_handler_async(route, msg)
695
+
696
+ for mw in reversed(middlewares):
697
+ nxt = call_next
698
+
699
+ async def wrapped(msg: RabbitMessage, _mw: Any = mw, _nxt: Any = nxt) -> Any:
700
+ return await _mw.consume_scope_async(_nxt, msg)
701
+
702
+ call_next = wrapped
703
+
704
+ chain = call_next
705
+ self._consume_chain_async_cache[id(route)] = chain
706
+
707
+ return await chain(message)
708
+
709
+ # ── Internal: handler invocation ─────────────────────────────────────
710
+
711
+ def _invoke_handler_sync(self, route: RouteDefinition, message: RabbitMessage) -> Any:
712
+ """Deserialize, resolve params, call handler (sync).
713
+
714
+ A ``DependencyScope`` is created whenever the *effective* resolver is
715
+ non-None (explicit OR auto-DI), so generator dependencies are always
716
+ tracked for teardown — including the documented zero-setup marker path.
717
+ The scope wraps BOTH resolution and handler invocation, so a resolution
718
+ failure still tears down any generators opened earlier in the same call.
719
+ """
720
+ # Deserialize body if serializer is available
721
+ body = self._deserialize_body(route, message)
722
+
723
+ # Create scope whenever the effective resolver (explicit or auto) is in
724
+ # play — this is the fix for the auto-DI generator-teardown leak.
725
+ resolver = self._effective_resolver(route.handler)
726
+ scope: DependencyScope | None = None
727
+ if resolver is not None and hasattr(resolver, "resolve"):
728
+ scope = DependencyScope()
729
+
730
+ # Resolve + invoke under a single try/finally so resolution failures
731
+ # also run generator teardown (generators opened before the failing one).
732
+ try:
733
+ kwargs = self._resolve_params(route, message, body, scope=scope)
734
+ return route.handler(**kwargs)
735
+ finally:
736
+ if scope is not None:
737
+ try:
738
+ scope.cleanup()
739
+ except Exception as cleanup_exc:
740
+ logger.error(
741
+ "DI generator cleanup raised an exception — possible resource leak: %s",
742
+ cleanup_exc,
743
+ exc_info=True,
744
+ )
745
+
746
+ async def _invoke_handler_async(self, route: RouteDefinition, message: RabbitMessage) -> Any:
747
+ """Deserialize, resolve params, call handler (async)."""
748
+ body = await self._deserialize_body_async(route, message)
749
+
750
+ resolver = self._effective_resolver(route.handler)
751
+ scope: DependencyScope | None = None
752
+ if resolver is not None and hasattr(resolver, "resolve_async"):
753
+ scope = DependencyScope()
754
+
755
+ try:
756
+ kwargs = await self._resolve_params_async(route, message, body, scope=scope)
757
+ result = route.handler(**kwargs)
758
+ # P5: cached async detection — avoids per-message hasattr(result, "__await__").
759
+ is_async = self._is_async_handler_cache.get(route.handler)
760
+ if is_async is None:
761
+ import asyncio as _aio
762
+ is_async = _aio.iscoroutinefunction(route.handler)
763
+ self._is_async_handler_cache[route.handler] = is_async
764
+ if is_async:
765
+ result = await result
766
+ return result
767
+ finally:
768
+ if scope is not None:
769
+ try:
770
+ await scope.cleanup_async()
771
+ except Exception as cleanup_exc:
772
+ logger.error(
773
+ "DI generator cleanup raised an exception — possible resource leak: %s",
774
+ cleanup_exc,
775
+ exc_info=True,
776
+ )
777
+
778
+ def _deserialize_body(self, route: RouteDefinition, message: RabbitMessage) -> Any:
779
+ """Deserialize message body using the route's serializer."""
780
+ serializer = route.serializer_override or self._serializer
781
+ if serializer is None:
782
+ return message.body
783
+ # P3: cached hasattr check — avoids a per-message attribute lookup.
784
+ can_decode = self._has_decode_cache.get(serializer)
785
+ if can_decode is None:
786
+ can_decode = hasattr(serializer, "decode")
787
+ self._has_decode_cache[serializer] = can_decode
788
+ if can_decode:
789
+ target_type = self._get_body_type(route)
790
+ if target_type is not None and target_type is not bytes:
791
+ return serializer.decode(message.body, target_type)
792
+ return message.body
793
+
794
+ async def _deserialize_body_async(self, route: RouteDefinition, message: RabbitMessage) -> Any:
795
+ """Async body deserialization (M10).
796
+
797
+ Identical to :meth:`_deserialize_body`, except a large body is decoded
798
+ in a worker thread via ``asyncio.to_thread`` so a multi-MB
799
+ JSON/msgspec/pydantic parse doesn't block the event loop — which would
800
+ otherwise stall heartbeats, publisher confirms, and every other
801
+ consumer sharing the loop. Small bodies decode inline (the thread hop
802
+ costs more than the parse).
803
+ """
804
+ serializer = route.serializer_override or self._serializer
805
+ if serializer is None:
806
+ return message.body
807
+ can_decode = self._has_decode_cache.get(serializer)
808
+ if can_decode is None:
809
+ can_decode = hasattr(serializer, "decode")
810
+ self._has_decode_cache[serializer] = can_decode
811
+ if can_decode:
812
+ target_type = self._get_body_type(route)
813
+ if target_type is not None and target_type is not bytes:
814
+ if len(message.body) >= _DECODE_OFFLOAD_THRESHOLD_BYTES:
815
+ import asyncio
816
+
817
+ return await asyncio.to_thread(serializer.decode, message.body, target_type)
818
+ return serializer.decode(message.body, target_type)
819
+ return message.body
820
+
821
+ def _get_body_type(self, route: RouteDefinition) -> type | None:
822
+ """Get the body parameter type from the handler signature (cached per handler)."""
823
+ handler = route.handler
824
+ if handler in self._body_type_cache:
825
+ return self._body_type_cache[handler]
826
+ body_type = self._compute_body_type(route)
827
+ self._body_type_cache[handler] = body_type
828
+ return body_type
829
+
830
+ def _compute_body_type(self, route: RouteDefinition) -> type | None:
831
+ """Resolve the body parameter type. Returns None if none or if it is bytes.
832
+
833
+ Uses ``typing.get_type_hints()`` so that string annotations produced by
834
+ ``from __future__ import annotations`` are resolved to their real types
835
+ before being handed to the serializer. Falls back to the raw
836
+ ``inspect.Parameter.annotation`` value when ``get_type_hints()`` cannot
837
+ resolve the annotation (e.g. forward references that are not yet defined).
838
+ """
839
+ import inspect
840
+ import typing
841
+
842
+ try:
843
+ hints = typing.get_type_hints(route.handler, include_extras=True)
844
+ except Exception:
845
+ hints = {}
846
+
847
+ sig = inspect.signature(route.handler)
848
+ for param_name, param in sig.parameters.items():
849
+ # Prefer the resolved hint; fall back to the raw annotation.
850
+ ann = hints.get(param_name, param.annotation)
851
+ if ann is inspect.Parameter.empty:
852
+ continue
853
+ # Skip RabbitMessage type
854
+ if is_rabbit_message_annotation(ann):
855
+ continue
856
+ # Skip Annotated types (DI marker)
857
+ origin = getattr(ann, "__metadata__", None)
858
+ if origin is not None:
859
+ continue
860
+ # First non-special parameter is the body type
861
+ return ann # type: ignore[no-any-return]
862
+ return None
863
+
864
+ def _resolve_params(
865
+ self,
866
+ route: RouteDefinition,
867
+ message: RabbitMessage,
868
+ body: Any,
869
+ scope: Any | None = None,
870
+ ) -> dict[str, Any]:
871
+ """Resolve handler parameters.
872
+
873
+ Uses DI resolver if available, otherwise falls back to simple
874
+ body + message injection.
875
+ """
876
+ resolver = self._effective_resolver(route.handler)
877
+ if resolver is not None and hasattr(resolver, "resolve"):
878
+ return resolver.resolve(route.handler, message, self._context_repo, body, scope=scope) # type: ignore[no-any-return]
879
+
880
+ # P4: precomputed binding plan — avoids per-message inspect.signature iteration,
881
+ # is_rabbit_message_annotation string checks, and param.default lookups.
882
+ # The plan is a list of (param_name, action) where action is
883
+ # "message", "body", or "skip" (use default). Computed once per handler.
884
+ plan = self._binding_plan_cache.get(route.handler)
885
+ if plan is None:
886
+ import inspect
887
+ sig = self._sig_cache.get(route.handler)
888
+ if sig is None:
889
+ sig = inspect.signature(route.handler)
890
+ self._sig_cache[route.handler] = sig
891
+ plan = []
892
+ body_injected = False
893
+ for param_name, param in sig.parameters.items():
894
+ # Skip *args and **kwargs — they can't be passed via **kwargs
895
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
896
+ plan.append((param_name, "skip"))
897
+ continue
898
+ ann = param.annotation
899
+ if is_rabbit_message_annotation(ann):
900
+ plan.append((param_name, "message"))
901
+ elif not body_injected:
902
+ plan.append((param_name, "body"))
903
+ body_injected = True
904
+ elif param.default is not inspect.Parameter.empty:
905
+ plan.append((param_name, "skip"))
906
+ else:
907
+ plan.append((param_name, "message"))
908
+ self._binding_plan_cache[route.handler] = plan
909
+
910
+ # Execute the plan — a tight loop with no per-message reflection.
911
+ kwargs: dict[str, Any] = {}
912
+ for param_name, action in plan:
913
+ if action == "message":
914
+ kwargs[param_name] = message
915
+ elif action == "body":
916
+ kwargs[param_name] = body
917
+ # "skip" → use default (omit from kwargs)
918
+ return kwargs
919
+
920
+ async def _resolve_params_async(
921
+ self,
922
+ route: RouteDefinition,
923
+ message: RabbitMessage,
924
+ body: Any,
925
+ scope: Any | None = None,
926
+ ) -> dict[str, Any]:
927
+ """Resolve handler parameters (async variant).
928
+
929
+ Uses async DI resolver if available, otherwise falls back to sync resolve.
930
+ """
931
+ resolver = self._effective_resolver(route.handler)
932
+ if resolver is not None and hasattr(resolver, "resolve_async"):
933
+ return await resolver.resolve_async( # type: ignore[no-any-return]
934
+ route.handler, message, self._context_repo, body, scope=scope
935
+ )
936
+ # Fall back to sync resolve (fast path for marker-free handlers)
937
+ return self._resolve_params(route, message, body, scope=scope)
938
+
939
+ def _effective_resolver(self, handler: Any) -> Any | None:
940
+ """Return the resolver to use for a handler.
941
+
942
+ Explicit `di_resolver` always wins. Otherwise, handlers that use DI markers
943
+ (Depends/Header/Path/Context) get a lazily-created default DIResolver, so the
944
+ documented markers work without the caller wiring one. Marker-free handlers
945
+ return None → the fast body/message fallback, unchanged.
946
+ """
947
+ if self._di_resolver is not None:
948
+ return self._di_resolver
949
+ if not self._handler_needs_di(handler):
950
+ return None
951
+ if self._auto_resolver is None:
952
+ self._auto_resolver = DIResolver()
953
+ return self._auto_resolver
954
+
955
+ def _handler_needs_di(self, handler: Any) -> bool:
956
+ """True if any parameter is Annotated with a DI marker. Cached per handler.
957
+
958
+ L11: uses the SAME hint-resolution strength as ``DIResolver`` itself
959
+ (``get_type_hints_with_fallback`` — includes the closure-``localns``
960
+ retry) so this detector and the resolver it gates never diverge. A
961
+ weaker, 2-attempt version used to live here; a closure-scoped
962
+ ``Depends(...)`` annotation could resolve fine for ``DIResolver`` but
963
+ still be mis-detected as "no DI needed" by this method, silently
964
+ binding the marked parameter to the message body instead.
965
+ """
966
+ cached = self._needs_di_cache.get(handler)
967
+ if cached is not None:
968
+ return cached
969
+
970
+ from rabbitkit.di.context import Context, Header, Path
971
+ from rabbitkit.di.depends import Depends
972
+ from rabbitkit.di.resolver import get_type_hints_with_fallback
973
+
974
+ hints = get_type_hints_with_fallback(handler)
975
+
976
+ markers = (Depends, Header, Path, Context)
977
+ needs = any(any(isinstance(m, markers) for m in getattr(ann, "__metadata__", ())) for ann in hints.values())
978
+ self._needs_di_cache[handler] = needs
979
+ return needs
980
+
981
+ # ── Internal: result publishing ──────────────────────────────────────
982
+
983
+ def _compose_publish_sync(
984
+ self,
985
+ route: RouteDefinition,
986
+ publish_fn: Callable[[MessageEnvelope], PublishOutcome],
987
+ ) -> Callable[[MessageEnvelope, Callable[[MessageEnvelope], PublishOutcome]], Any]:
988
+ """Compose this route's ``publish_scope`` middlewares into a reusable chain.
989
+
990
+ So a route that carries e.g. a signing/tracing middleware applies it to
991
+ the results it publishes. (Standalone producer publishes via
992
+ ``broker.publish`` are not route-scoped and apply publish middlewares
993
+ manually — see docs.)
994
+
995
+ The composed chain is cached per route (keyed by ``id(route)``) — the
996
+ middleware list is fixed after registration, so rebuild once per route
997
+ instead of allocating N closures per message (mirrors the consume cache).
998
+
999
+ L-1: ``publish_fn`` is NOT captured in the cached closure — it is threaded
1000
+ through at invocation time as the second argument, so a later call with a
1001
+ different ``publish_fn`` actually uses the new one (previously the first
1002
+ ``publish_fn`` was silently captured and reused forever).
1003
+ """
1004
+ cached = self._publish_chain_cache.get(id(route))
1005
+ if cached is not None:
1006
+ return cached
1007
+
1008
+ # The innermost shim defers to ``publish_fn`` supplied at call time.
1009
+ def leaf(env: MessageEnvelope, fn: Callable[[MessageEnvelope], PublishOutcome]) -> Any:
1010
+ return fn(env)
1011
+
1012
+ chain: Callable[[MessageEnvelope, Callable[[MessageEnvelope], PublishOutcome]], Any] = leaf
1013
+ for mw in reversed(route.route_middlewares):
1014
+ nxt = chain
1015
+
1016
+ def wrapped(
1017
+ env: MessageEnvelope,
1018
+ fn: Callable[[MessageEnvelope], PublishOutcome],
1019
+ _mw: Any = mw,
1020
+ _nxt: Any = nxt,
1021
+ ) -> Any:
1022
+ # Bind the current publish_fn into the call_next shim so the
1023
+ # middleware's publish_scope(call_next, env) signature is unchanged.
1024
+ return _mw.publish_scope(lambda e: _nxt(e, fn), env)
1025
+
1026
+ chain = wrapped
1027
+ self._publish_chain_cache[id(route)] = chain
1028
+ return chain
1029
+
1030
+ def _publish_result_sync(
1031
+ self,
1032
+ route: RouteDefinition,
1033
+ message: RabbitMessage,
1034
+ result: Any,
1035
+ publish_fn: Callable[[MessageEnvelope], PublishOutcome] | None,
1036
+ ) -> bool:
1037
+ """Publish handler result (Contract 5 precedence).
1038
+
1039
+ Returns False only when a publish was attempted and failed, so the
1040
+ caller can avoid acking a message whose result was lost — the
1041
+ caller nacks with ``requeue=True`` instead (see ``process_sync``).
1042
+
1043
+ L1: a nack+requeue here re-runs the handler from scratch on
1044
+ redelivery, including any side effects it already performed —
1045
+ this is only safe if handlers are idempotent (the same baseline
1046
+ assumption at-least-once delivery already requires everywhere
1047
+ else; see ``docs/rabbitmq-retry-architecture.md``). If
1048
+ ``message.redelivered`` is already ``True``, this is not the
1049
+ first time this exact message has hit a failing result publish —
1050
+ logged at ERROR (vs. WARNING for a first attempt) so a sustained
1051
+ publish outage that would otherwise hot-loop silently is loud and
1052
+ alertable via log-based monitoring.
1053
+ """
1054
+ if publish_fn is None:
1055
+ return True
1056
+
1057
+ envelope = self._build_result_envelope(route, message, result)
1058
+ if envelope is None:
1059
+ return True
1060
+
1061
+ outcome = self._compose_publish_sync(route, publish_fn)(envelope, publish_fn)
1062
+ if not outcome.ok:
1063
+ _log_result_publish_failure(message, outcome)
1064
+ return False
1065
+ return True
1066
+
1067
+ def _compose_publish_async(
1068
+ self,
1069
+ route: RouteDefinition,
1070
+ publish_fn: Callable[[MessageEnvelope], Awaitable[PublishOutcome]],
1071
+ ) -> Callable[[MessageEnvelope, Callable[[MessageEnvelope], Awaitable[PublishOutcome]]], Awaitable[Any]]:
1072
+ """Async variant of :meth:`_compose_publish_sync`.
1073
+
1074
+ The composed chain is cached per route (keyed by ``id(route)``) — see
1075
+ :meth:`_compose_publish_sync` for the rationale.
1076
+
1077
+ L-1: ``publish_fn`` is NOT captured in the cached closure — it is threaded
1078
+ through at invocation time as the second argument.
1079
+ """
1080
+ cached = self._publish_chain_async_cache.get(id(route))
1081
+ if cached is not None:
1082
+ return cached
1083
+
1084
+ async def leaf(
1085
+ env: MessageEnvelope,
1086
+ fn: Callable[[MessageEnvelope], Awaitable[PublishOutcome]],
1087
+ ) -> Any:
1088
+ return await fn(env)
1089
+
1090
+ chain: Callable[[MessageEnvelope, Callable[[MessageEnvelope], Awaitable[PublishOutcome]]], Awaitable[Any]] = (
1091
+ leaf
1092
+ )
1093
+ for mw in reversed(route.route_middlewares):
1094
+ nxt = chain
1095
+
1096
+ async def wrapped(
1097
+ env: MessageEnvelope,
1098
+ fn: Callable[[MessageEnvelope], Awaitable[PublishOutcome]],
1099
+ _mw: Any = mw,
1100
+ _nxt: Any = nxt,
1101
+ ) -> Any:
1102
+ return await _mw.publish_scope_async(lambda e: _nxt(e, fn), env)
1103
+
1104
+ chain = wrapped
1105
+ self._publish_chain_async_cache[id(route)] = chain
1106
+ return chain
1107
+
1108
+ async def _publish_result_async(
1109
+ self,
1110
+ route: RouteDefinition,
1111
+ message: RabbitMessage,
1112
+ result: Any,
1113
+ publish_fn: Callable[[MessageEnvelope], Awaitable[PublishOutcome]] | None,
1114
+ ) -> bool:
1115
+ """Publish handler result (async, Contract 5 precedence).
1116
+
1117
+ Returns False only when a publish was attempted and failed. See
1118
+ :meth:`_publish_result_sync` (L1) for the redelivery-escalation
1119
+ rationale — identical here.
1120
+ """
1121
+ if publish_fn is None:
1122
+ return True
1123
+
1124
+ envelope = self._build_result_envelope(route, message, result)
1125
+ if envelope is None:
1126
+ return True
1127
+
1128
+ outcome = await self._compose_publish_async(route, publish_fn)(envelope, publish_fn)
1129
+ if not outcome.ok:
1130
+ _log_result_publish_failure(message, outcome)
1131
+ return False
1132
+ return True
1133
+
1134
+ def _build_result_envelope(
1135
+ self,
1136
+ route: RouteDefinition,
1137
+ message: RabbitMessage,
1138
+ result: Any,
1139
+ ) -> MessageEnvelope | None:
1140
+ """Build MessageEnvelope from handler result.
1141
+
1142
+ Contract 5 precedence:
1143
+ 1. None return → no publish
1144
+ 2. reply_to → RPC reply (takes precedence)
1145
+ 3. result_publisher → publish to configured exchange/routing_key
1146
+ 4. Both → reply_to wins
1147
+ """
1148
+ if result is None:
1149
+ return None
1150
+
1151
+ user_envelope = result if isinstance(result, MessageEnvelope) else None
1152
+ body = self._serialize_result(route, result)
1153
+
1154
+ # Determine destination (Contract 5)
1155
+ if message.reply_to:
1156
+ # RPC reply takes precedence
1157
+ if user_envelope is not None:
1158
+ # Preserve user-provided fields (headers, message_id,
1159
+ # content_type, priority, expiration, ...); only the
1160
+ # precedence-driven destination is merged in.
1161
+ return dataclasses.replace(
1162
+ user_envelope,
1163
+ routing_key=message.reply_to,
1164
+ exchange="",
1165
+ correlation_id=message.correlation_id,
1166
+ )
1167
+ return MessageEnvelope(
1168
+ routing_key=message.reply_to,
1169
+ body=body,
1170
+ exchange="",
1171
+ correlation_id=message.correlation_id,
1172
+ )
1173
+
1174
+ if route.result_publisher is not None:
1175
+ exchange_name = route.result_publisher.resolve_exchange_name()
1176
+ if user_envelope is not None:
1177
+ # Override only exchange/routing_key; keep user fields.
1178
+ return dataclasses.replace(
1179
+ user_envelope,
1180
+ routing_key=route.result_publisher.routing_key,
1181
+ exchange=exchange_name,
1182
+ )
1183
+ return MessageEnvelope(
1184
+ routing_key=route.result_publisher.routing_key,
1185
+ body=body,
1186
+ exchange=exchange_name,
1187
+ )
1188
+
1189
+ if user_envelope is not None:
1190
+ logger.warning(
1191
+ "handler returned a MessageEnvelope but route has no result_publisher"
1192
+ " and message has no reply_to; result dropped"
1193
+ )
1194
+ return None
1195
+
1196
+ def _serialize_result(self, route: RouteDefinition, result: Any) -> bytes:
1197
+ """Serialize handler return value to bytes."""
1198
+ if isinstance(result, bytes):
1199
+ return result
1200
+ if isinstance(result, str):
1201
+ return result.encode("utf-8")
1202
+ if isinstance(result, MessageEnvelope):
1203
+ return result.body
1204
+
1205
+ serializer = route.serializer_override or self._serializer
1206
+ if serializer is not None and hasattr(serializer, "encode"):
1207
+ return serializer.encode(result)
1208
+
1209
+ # Fallback: JSON encode
1210
+ import json
1211
+
1212
+ return json.dumps(result).encode("utf-8")
1213
+
1214
+ # ── Internal: exception handling ─────────────────────────────────────
1215
+
1216
+ def _handle_sync_exception(
1217
+ self,
1218
+ route: RouteDefinition,
1219
+ message: RabbitMessage,
1220
+ exc: Exception,
1221
+ ) -> None:
1222
+ """Handle exception in sync pipeline per AckPolicy (Contract 1)."""
1223
+ if message.is_settled:
1224
+ # Already settled (e.g., MANUAL mode handler settled then raised)
1225
+ logger.warning("Exception after settlement: %s", exc)
1226
+ return
1227
+
1228
+ # RetryMiddleware tags exhausted/permanent failures as terminal. Dead-letter
1229
+ # them (reject → source-queue DLX → DLQ) rather than re-classifying: an
1230
+ # *exhausted transient* error would otherwise be re-classified TRANSIENT and
1231
+ # nack(requeue=True)'d straight back into a hot loop, never reaching the DLQ.
1232
+ # MANUAL is excluded — retry is incompatible with MANUAL (handler owns ack).
1233
+ if getattr(exc, "_rabbitkit_terminal", False) and route.ack_policy != AckPolicy.MANUAL:
1234
+ message.reject(requeue=False)
1235
+ return
1236
+
1237
+ # M6: 2-strike cap on the transient hot-loop. A transient error on a
1238
+ # message the broker has already redelivered escalates to the DLQ
1239
+ # instead of an unbounded nack-requeue. Only AUTO requeues transients;
1240
+ # opt-in via ConsumerConfig.reject_transient_on_redelivery.
1241
+ if (
1242
+ self._reject_transient_on_redelivery
1243
+ and message.redelivered
1244
+ and route.ack_policy == AckPolicy.AUTO
1245
+ and classify_error(exc).severity == ErrorSeverity.TRANSIENT
1246
+ ):
1247
+ logger.warning(
1248
+ "Transient error on an already-redelivered message; rejecting to DLQ "
1249
+ "instead of requeuing again (reject_transient_on_redelivery)",
1250
+ exc_info=True,
1251
+ )
1252
+ message.reject(requeue=False)
1253
+ return
1254
+
1255
+ _ACK_STRATEGIES[route.ack_policy].on_error(message, exc)
1256
+
1257
+ async def _handle_async_exception(
1258
+ self,
1259
+ route: RouteDefinition,
1260
+ message: RabbitMessage,
1261
+ exc: Exception,
1262
+ ) -> None:
1263
+ """Handle exception in async pipeline per AckPolicy (Contract 1)."""
1264
+ if message.is_settled:
1265
+ logger.warning("Exception after settlement: %s", exc)
1266
+ return
1267
+
1268
+ # See _handle_sync_exception: terminal (exhausted/permanent) failures
1269
+ # dead-letter directly instead of being re-classified into a hot loop.
1270
+ if getattr(exc, "_rabbitkit_terminal", False) and route.ack_policy != AckPolicy.MANUAL:
1271
+ await message.reject_async(requeue=False)
1272
+ return
1273
+
1274
+ # M6: 2-strike cap on the transient hot-loop (see _handle_sync_exception).
1275
+ if (
1276
+ self._reject_transient_on_redelivery
1277
+ and message.redelivered
1278
+ and route.ack_policy == AckPolicy.AUTO
1279
+ and classify_error(exc).severity == ErrorSeverity.TRANSIENT
1280
+ ):
1281
+ logger.warning(
1282
+ "Transient error on an already-redelivered message; rejecting to DLQ "
1283
+ "instead of requeuing again (reject_transient_on_redelivery)",
1284
+ exc_info=True,
1285
+ )
1286
+ await message.reject_async(requeue=False)
1287
+ return
1288
+
1289
+ await _ACK_STRATEGIES_ASYNC[route.ack_policy].on_error(message, exc)