rabbitkit 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|