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,651 @@
|
|
|
1
|
+
"""DeduplicationMiddleware — idempotent message processing via Redis SETNX.
|
|
2
|
+
|
|
3
|
+
Checks whether a message has already been processed by storing a dedup key
|
|
4
|
+
in Redis with TTL. Duplicate messages are silently acked and skipped.
|
|
5
|
+
|
|
6
|
+
If Redis is unavailable, behaviour depends on ``fallback_on_redis_error``:
|
|
7
|
+
- ``True`` (default): process the message anyway (at-least-once)
|
|
8
|
+
- ``False``: re-raise the Redis error (fail fast)
|
|
9
|
+
|
|
10
|
+
Mark policy (``DeduplicationConfig.mark_policy``):
|
|
11
|
+
- ``"on_success"`` (default): check for the key before the handler (no
|
|
12
|
+
write), mark it only after the handler returns successfully. Crash-safe:
|
|
13
|
+
a consumer killed mid-handler (OOM/SIGKILL) leaves no mark, so the
|
|
14
|
+
broker's redelivery is processed rather than dropped as a duplicate.
|
|
15
|
+
Risk: concurrent deliveries of the same message may both pass the dedup
|
|
16
|
+
check and both process (at-least-once).
|
|
17
|
+
- ``"on_start"``: mark before calling the handler, preventing concurrent
|
|
18
|
+
duplicate processing. WARNING — can cause MESSAGE LOSS: if the process
|
|
19
|
+
crashes after marking but before the handler finishes, the broker's
|
|
20
|
+
redelivery is skipped as a duplicate. Use only when duplicate execution
|
|
21
|
+
is worse than losing a message. Also: if the handler fails and no
|
|
22
|
+
RetryMiddleware is on the route (or the route's classifier calls it
|
|
23
|
+
permanent), the retry may be skipped — see the H8 note below for the one
|
|
24
|
+
case this middleware CAN detect and correct for.
|
|
25
|
+
- ``"claim"``: two-state. Before the handler, atomically claim the key as
|
|
26
|
+
``in-flight`` with ``processing_timeout`` as its TTL; on success flip it
|
|
27
|
+
to ``completed`` with the full ``ttl``. A concurrent duplicate that sees
|
|
28
|
+
a live in-flight claim is handled per ``on_in_flight``:
|
|
29
|
+
``"nack_requeue"`` (default — the copy comes back and retries, so it is
|
|
30
|
+
NOT lost if the claiming consumer dies) or ``"ack_skip"``. A crash
|
|
31
|
+
mid-handler simply lets the claim expire, after which the redelivery is
|
|
32
|
+
processed. Blocks concurrent duplicates AND is crash-safe — provided
|
|
33
|
+
``processing_timeout`` comfortably exceeds the worst-case handler
|
|
34
|
+
duration; a handler that outlives its claim lets a duplicate start
|
|
35
|
+
while it is still running.
|
|
36
|
+
|
|
37
|
+
Composing with RetryMiddleware (H8)
|
|
38
|
+
------------------------------------
|
|
39
|
+
If this middleware is OUTER of a ``RetryMiddleware`` on the same route (i.e.
|
|
40
|
+
listed before it in ``middlewares=[...]``), a transient failure that
|
|
41
|
+
``RetryMiddleware`` requeues (delay-queue publish, or nack+redeliver if that
|
|
42
|
+
publish itself failed) is invisible from here as an exception —
|
|
43
|
+
``RetryMiddleware`` deliberately swallows it so an outer
|
|
44
|
+
``ExceptionMiddleware`` doesn't treat a retry-in-progress as terminal. That
|
|
45
|
+
would otherwise look exactly like "the handler ran and returned `None`",
|
|
46
|
+
which under ``mark_policy="on_success"`` would incorrectly mark the message
|
|
47
|
+
as processed — dropping the later retry redelivery (same dedup key) as a
|
|
48
|
+
duplicate instead of actually processing it (silent message loss). Both
|
|
49
|
+
``consume_scope`` implementations here check for
|
|
50
|
+
``rabbitkit.core.types.REQUEUED_FOR_RETRY`` (the sentinel
|
|
51
|
+
``RetryMiddleware.consume_scope``/``consume_scope_async`` return instead of
|
|
52
|
+
``None`` in that case) and delete/skip the dedup key instead of marking it,
|
|
53
|
+
for both ``mark_policy`` values — including ``"on_start"``, where this
|
|
54
|
+
retroactively undoes the premature mark once retry signals a requeue. A
|
|
55
|
+
custom middleware that also wraps ``call_next`` and cares about this
|
|
56
|
+
distinction should check for the same sentinel.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import hashlib
|
|
62
|
+
import logging
|
|
63
|
+
import threading
|
|
64
|
+
from collections import OrderedDict
|
|
65
|
+
from collections.abc import Awaitable, Callable
|
|
66
|
+
from typing import Any
|
|
67
|
+
|
|
68
|
+
from rabbitkit.core.config import DeduplicationConfig
|
|
69
|
+
from rabbitkit.core.message import RabbitMessage
|
|
70
|
+
from rabbitkit.core.types import REQUEUED_FOR_RETRY
|
|
71
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
# Redis values for mark_policy="claim". Anything OTHER than the in-flight
|
|
76
|
+
# marker (including the legacy "1" written by on_success/on_start) is treated
|
|
77
|
+
# as completed, so switching an existing deployment to "claim" is safe.
|
|
78
|
+
_IN_FLIGHT = "in-flight"
|
|
79
|
+
_COMPLETED = "completed"
|
|
80
|
+
|
|
81
|
+
# F5: schema tag for stored-result envelopes. Bumped when the envelope shape
|
|
82
|
+
# changes; a mismatched tag degrades to skip-without-replay (never replays a
|
|
83
|
+
# result written by an incompatible version).
|
|
84
|
+
_RESULT_SCHEMA_VERSION = 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _encode_completed_with_result(result: object, max_bytes: int) -> str | None:
|
|
88
|
+
"""JSON envelope for a completed key carrying the handler result, or
|
|
89
|
+
``None`` when the result can't be stored (not JSON-serializable, or over
|
|
90
|
+
*max_bytes*) — callers then degrade to the plain completed mark."""
|
|
91
|
+
import json
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
encoded = json.dumps(
|
|
95
|
+
{"s": _COMPLETED, "v": _RESULT_SCHEMA_VERSION, "r": result},
|
|
96
|
+
separators=(",", ":"),
|
|
97
|
+
)
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
return None
|
|
100
|
+
if len(encoded.encode()) > max_bytes:
|
|
101
|
+
return None
|
|
102
|
+
return encoded
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _decode_stored_result(raw: object) -> tuple[bool, object]:
|
|
106
|
+
"""``(has_result, result)`` from a completed key's stored value.
|
|
107
|
+
|
|
108
|
+
Legacy values ("1", "completed", "in-flight" never reaches here) and
|
|
109
|
+
schema-mismatched or malformed envelopes decode as ``(False, None)`` —
|
|
110
|
+
the duplicate is skipped without replay, exactly the pre-F5 behavior."""
|
|
111
|
+
import json
|
|
112
|
+
|
|
113
|
+
if isinstance(raw, bytes):
|
|
114
|
+
try:
|
|
115
|
+
raw = raw.decode()
|
|
116
|
+
except UnicodeDecodeError:
|
|
117
|
+
return False, None
|
|
118
|
+
if not isinstance(raw, str) or not raw.startswith("{"):
|
|
119
|
+
return False, None
|
|
120
|
+
try:
|
|
121
|
+
envelope = json.loads(raw)
|
|
122
|
+
except ValueError:
|
|
123
|
+
return False, None
|
|
124
|
+
if not isinstance(envelope, dict) or envelope.get("v") != _RESULT_SCHEMA_VERSION:
|
|
125
|
+
return False, None
|
|
126
|
+
if envelope.get("s") != _COMPLETED or "r" not in envelope:
|
|
127
|
+
return False, None
|
|
128
|
+
return True, envelope["r"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DeduplicationMiddleware(BaseMiddleware):
|
|
132
|
+
"""Idempotent consumer middleware backed by Redis SETNX.
|
|
133
|
+
|
|
134
|
+
Usage::
|
|
135
|
+
|
|
136
|
+
import redis
|
|
137
|
+
mw = DeduplicationMiddleware(
|
|
138
|
+
redis_client=redis.Redis(),
|
|
139
|
+
config=DeduplicationConfig(key_source="message_id", ttl=86400),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
To prevent concurrent duplicate processing at the cost of retry safety::
|
|
143
|
+
|
|
144
|
+
mw = DeduplicationMiddleware(
|
|
145
|
+
redis_client=redis.Redis(),
|
|
146
|
+
config=DeduplicationConfig(mark_policy="on_start"),
|
|
147
|
+
)
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
redis_client: Any,
|
|
153
|
+
config: DeduplicationConfig | None = None,
|
|
154
|
+
*,
|
|
155
|
+
key_fn: Callable[[RabbitMessage], str] | None = None,
|
|
156
|
+
metrics_collector: Any | None = None,
|
|
157
|
+
metrics_config: Any | None = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
self._redis = redis_client
|
|
160
|
+
self._config = config or DeduplicationConfig()
|
|
161
|
+
self._key_fn = key_fn
|
|
162
|
+
# M9: optional -- emits `dedup_fallback_total` every time a Redis
|
|
163
|
+
# error causes this middleware to skip idempotency enforcement for a
|
|
164
|
+
# message (fallback_on_redis_error=True, the default). None is a no-op.
|
|
165
|
+
self._metrics_collector = metrics_collector
|
|
166
|
+
self._metrics_config = metrics_config
|
|
167
|
+
# Optional in-process LRU pre-filter — short-circuits Redis for keys we've
|
|
168
|
+
# already confirmed as processed. Only allocated when local_cache_size > 0.
|
|
169
|
+
# Evicts the oldest entry (FIFO) when capacity is reached.
|
|
170
|
+
self._local_cache: OrderedDict[str, None] | None = (
|
|
171
|
+
OrderedDict() if self._config.local_cache_size > 0 else None
|
|
172
|
+
)
|
|
173
|
+
# The cache is mutated from sync worker-pool daemon threads; OrderedDict
|
|
174
|
+
# mutation is not atomic (move_to_end/popitem can corrupt mid-eviction).
|
|
175
|
+
self._local_lock = threading.Lock()
|
|
176
|
+
|
|
177
|
+
def _record_fallback(self, message: RabbitMessage) -> None:
|
|
178
|
+
"""M9: log at ERROR (not WARNING — idempotency being silently
|
|
179
|
+
disabled for a message is an operational event worth alerting on,
|
|
180
|
+
not routine noise) and emit `dedup_fallback_total` if a metrics
|
|
181
|
+
collector is wired in."""
|
|
182
|
+
logger.error(
|
|
183
|
+
"Redis error during dedup check/mark; processing message anyway "
|
|
184
|
+
"(fallback_on_redis_error=True) — idempotency is NOT enforced for "
|
|
185
|
+
"this message. For workloads where a duplicate is unacceptable "
|
|
186
|
+
"(e.g. financial), set fallback_on_redis_error=False to fail closed instead.",
|
|
187
|
+
exc_info=True,
|
|
188
|
+
)
|
|
189
|
+
if self._metrics_collector is not None and self._metrics_config is not None:
|
|
190
|
+
queue = message.headers.get("x-rabbitkit-original-queue") or message.routing_key or "unknown"
|
|
191
|
+
self._metrics_collector.inc_counter(
|
|
192
|
+
self._metrics_config.dedup_fallback_total, {"queue": str(queue)}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ── Local LRU helpers ─────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
def _local_is_dup(self, key: str) -> bool:
|
|
198
|
+
"""True if key is already in the local cache (= confirmed processed)."""
|
|
199
|
+
if self._local_cache is None:
|
|
200
|
+
return False
|
|
201
|
+
with self._local_lock:
|
|
202
|
+
return key in self._local_cache
|
|
203
|
+
|
|
204
|
+
def _local_mark(self, key: str) -> None:
|
|
205
|
+
"""Record key in the local LRU; evicts oldest when at capacity."""
|
|
206
|
+
if self._local_cache is None:
|
|
207
|
+
return
|
|
208
|
+
with self._local_lock:
|
|
209
|
+
self._local_cache[key] = None
|
|
210
|
+
self._local_cache.move_to_end(key)
|
|
211
|
+
if len(self._local_cache) > self._config.local_cache_size:
|
|
212
|
+
self._local_cache.popitem(last=False)
|
|
213
|
+
|
|
214
|
+
def _local_remove(self, key: str) -> None:
|
|
215
|
+
"""Remove key from local cache (called when handler fails, key deleted from Redis)."""
|
|
216
|
+
if self._local_cache is not None:
|
|
217
|
+
with self._local_lock:
|
|
218
|
+
self._local_cache.pop(key, None)
|
|
219
|
+
|
|
220
|
+
def _cleanup_key_after_non_success(self, key: str) -> None:
|
|
221
|
+
"""Delete *key* from Redis + the local cache after a non-success
|
|
222
|
+
(handler exception, or a requeue signaled via REQUEUED_FOR_RETRY —
|
|
223
|
+
H8) so a later redelivery of the SAME message is not treated as a
|
|
224
|
+
duplicate and dropped."""
|
|
225
|
+
try:
|
|
226
|
+
self._redis.delete(key)
|
|
227
|
+
except Exception:
|
|
228
|
+
logger.warning("Redis error during dedup key cleanup after non-success", exc_info=True)
|
|
229
|
+
self._local_remove(key)
|
|
230
|
+
|
|
231
|
+
async def _cleanup_key_after_non_success_async(self, key: str) -> None:
|
|
232
|
+
"""Async variant of :meth:`_cleanup_key_after_non_success`."""
|
|
233
|
+
try:
|
|
234
|
+
await self._redis.delete(key)
|
|
235
|
+
except Exception:
|
|
236
|
+
logger.warning("Redis error during dedup key cleanup after non-success", exc_info=True)
|
|
237
|
+
self._local_remove(key)
|
|
238
|
+
|
|
239
|
+
# ── Key extraction ────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def _extract_key(self, message: RabbitMessage) -> str:
|
|
242
|
+
"""Build the dedup key from the message.
|
|
243
|
+
|
|
244
|
+
Resolution:
|
|
245
|
+
1. Custom ``key_fn`` (highest priority)
|
|
246
|
+
2. ``config.key_source``:
|
|
247
|
+
- ``"message_id"`` → ``message.message_id``
|
|
248
|
+
- ``"correlation_id"`` → ``message.correlation_id``
|
|
249
|
+
- ``"body_hash"`` → SHA-256 hex digest of ``message.body``
|
|
250
|
+
If the selected id field is empty/None we fall back to a SHA-256 body
|
|
251
|
+
hash (with a warning) instead of collapsing every id-less message to a
|
|
252
|
+
single constant key.
|
|
253
|
+
"""
|
|
254
|
+
if self._key_fn is not None:
|
|
255
|
+
raw = self._key_fn(message)
|
|
256
|
+
elif self._config.key_source == "message_id":
|
|
257
|
+
raw = message.message_id or ""
|
|
258
|
+
elif self._config.key_source == "correlation_id":
|
|
259
|
+
raw = message.correlation_id or ""
|
|
260
|
+
elif self._config.key_source == "body_hash":
|
|
261
|
+
raw = hashlib.sha256(message.body).hexdigest()
|
|
262
|
+
else:
|
|
263
|
+
# Unknown key_source — fall back to message_id
|
|
264
|
+
logger.warning(
|
|
265
|
+
"Unknown key_source %r, falling back to message_id",
|
|
266
|
+
self._config.key_source,
|
|
267
|
+
)
|
|
268
|
+
raw = message.message_id or ""
|
|
269
|
+
|
|
270
|
+
# Empty raw key (id field missing) → fall back to body hash so distinct
|
|
271
|
+
# id-less messages are NOT collapsed onto a single constant key.
|
|
272
|
+
if not raw:
|
|
273
|
+
logger.warning(
|
|
274
|
+
"key_source=%r resolved to an empty id for message (routing_key=%r); falling back to body hash.",
|
|
275
|
+
self._config.key_source,
|
|
276
|
+
message.routing_key,
|
|
277
|
+
)
|
|
278
|
+
raw = hashlib.sha256(message.body).hexdigest()
|
|
279
|
+
|
|
280
|
+
return f"{self._config.key_prefix}:{raw}"
|
|
281
|
+
|
|
282
|
+
def _mark_key(self, key: str, message: RabbitMessage) -> bool:
|
|
283
|
+
"""Attempt to mark key as processed (sync). Returns True if this is a new key.
|
|
284
|
+
|
|
285
|
+
Checks the local LRU cache first to avoid a Redis round-trip for keys
|
|
286
|
+
we've already confirmed as processed in this process.
|
|
287
|
+
"""
|
|
288
|
+
if self._local_is_dup(key):
|
|
289
|
+
return False
|
|
290
|
+
try:
|
|
291
|
+
result = bool(self._redis.set(key, "1", nx=True, ex=self._config.ttl))
|
|
292
|
+
except Exception:
|
|
293
|
+
if not self._config.fallback_on_redis_error:
|
|
294
|
+
raise
|
|
295
|
+
self._record_fallback(message)
|
|
296
|
+
return True
|
|
297
|
+
if result:
|
|
298
|
+
self._local_mark(key)
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
async def _mark_key_async(self, key: str, message: RabbitMessage) -> bool:
|
|
302
|
+
"""Attempt to mark key as processed (async). Returns True if this is a new key.
|
|
303
|
+
|
|
304
|
+
Checks the local LRU cache first to avoid a Redis round-trip for keys
|
|
305
|
+
we've already confirmed as processed in this process.
|
|
306
|
+
"""
|
|
307
|
+
if self._local_is_dup(key):
|
|
308
|
+
return False
|
|
309
|
+
try:
|
|
310
|
+
result = bool(await self._redis.set(key, "1", nx=True, ex=self._config.ttl))
|
|
311
|
+
except Exception:
|
|
312
|
+
if not self._config.fallback_on_redis_error:
|
|
313
|
+
raise
|
|
314
|
+
self._record_fallback(message)
|
|
315
|
+
return True
|
|
316
|
+
if result:
|
|
317
|
+
self._local_mark(key)
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
# ── Consume-side hooks ────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
def consume_scope(
|
|
323
|
+
self,
|
|
324
|
+
call_next: Callable[[RabbitMessage], Any],
|
|
325
|
+
message: RabbitMessage,
|
|
326
|
+
) -> Any:
|
|
327
|
+
"""Sync: check dedup → skip duplicate → call handler."""
|
|
328
|
+
key = self._extract_key(message)
|
|
329
|
+
|
|
330
|
+
if self._config.mark_policy == "claim":
|
|
331
|
+
return self._consume_claim_sync(call_next, message, key)
|
|
332
|
+
|
|
333
|
+
if self._config.mark_policy == "on_start":
|
|
334
|
+
# _mark_key checks local cache first, then Redis
|
|
335
|
+
is_new = self._mark_key(key, message)
|
|
336
|
+
if not is_new:
|
|
337
|
+
logger.debug("Duplicate message detected (key=%s); acking and skipping", key)
|
|
338
|
+
if not message.is_settled:
|
|
339
|
+
message.ack()
|
|
340
|
+
return None
|
|
341
|
+
result = call_next(message)
|
|
342
|
+
if result is REQUEUED_FOR_RETRY:
|
|
343
|
+
# H8: an inner RetryMiddleware requeued the failed handler
|
|
344
|
+
# rather than succeeding — undo the premature on_start mark
|
|
345
|
+
# so the retry redelivery is not dropped as a duplicate.
|
|
346
|
+
self._cleanup_key_after_non_success(key)
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
# mark_policy == "on_success": check (no write) → handler → mark.
|
|
350
|
+
# The key is written only AFTER the handler returns successfully, so a
|
|
351
|
+
# consumer killed mid-handler (OOM/SIGKILL) leaves no mark and the
|
|
352
|
+
# broker's redelivery is processed instead of dropped as a duplicate.
|
|
353
|
+
# A handler exception likewise leaves nothing behind — no cleanup
|
|
354
|
+
# needed (and deleting here could erase a concurrent delivery's
|
|
355
|
+
# legitimate success-mark).
|
|
356
|
+
if self._local_is_dup(key):
|
|
357
|
+
logger.debug("Duplicate message detected in local cache (key=%s); acking and skipping", key)
|
|
358
|
+
if not message.is_settled:
|
|
359
|
+
message.ack()
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
already_seen = bool(self._redis.exists(key))
|
|
364
|
+
except Exception:
|
|
365
|
+
if not self._config.fallback_on_redis_error:
|
|
366
|
+
raise
|
|
367
|
+
self._record_fallback(message)
|
|
368
|
+
return call_next(message)
|
|
369
|
+
|
|
370
|
+
if already_seen:
|
|
371
|
+
logger.debug("Duplicate message detected (key=%s); acking and skipping", key)
|
|
372
|
+
if not message.is_settled:
|
|
373
|
+
message.ack()
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
result = call_next(message)
|
|
377
|
+
if result is REQUEUED_FOR_RETRY:
|
|
378
|
+
# H8: an inner RetryMiddleware requeued the failed handler instead
|
|
379
|
+
# of it actually succeeding. Nothing was marked yet, so the retry
|
|
380
|
+
# redelivery (same dedup key) passes the dedup check — just skip
|
|
381
|
+
# the mark.
|
|
382
|
+
return result
|
|
383
|
+
# Handler succeeded — mark now. Never raise past this point, even with
|
|
384
|
+
# fallback_on_redis_error=False: the handler's side effects are done,
|
|
385
|
+
# and raising would nack → redeliver → a GUARANTEED duplicate
|
|
386
|
+
# execution, worse than the unmarked-key window it would signal.
|
|
387
|
+
try:
|
|
388
|
+
self._redis.set(key, "1", nx=True, ex=self._config.ttl)
|
|
389
|
+
except Exception:
|
|
390
|
+
self._record_fallback(message)
|
|
391
|
+
return result
|
|
392
|
+
self._local_mark(key)
|
|
393
|
+
return result
|
|
394
|
+
|
|
395
|
+
async def consume_scope_async(
|
|
396
|
+
self,
|
|
397
|
+
call_next: Callable[[RabbitMessage], Awaitable[Any]],
|
|
398
|
+
message: RabbitMessage,
|
|
399
|
+
) -> Any:
|
|
400
|
+
"""Async: check dedup → skip duplicate → call handler."""
|
|
401
|
+
key = self._extract_key(message)
|
|
402
|
+
|
|
403
|
+
if self._config.mark_policy == "claim":
|
|
404
|
+
return await self._consume_claim_async(call_next, message, key)
|
|
405
|
+
|
|
406
|
+
if self._config.mark_policy == "on_start":
|
|
407
|
+
# _mark_key_async checks local cache first, then Redis
|
|
408
|
+
is_new = await self._mark_key_async(key, message)
|
|
409
|
+
if not is_new:
|
|
410
|
+
logger.debug("Duplicate message detected (key=%s); acking and skipping", key)
|
|
411
|
+
if not message.is_settled:
|
|
412
|
+
await message.ack_async()
|
|
413
|
+
return None
|
|
414
|
+
result = await call_next(message)
|
|
415
|
+
if result is REQUEUED_FOR_RETRY:
|
|
416
|
+
# H8: an inner RetryMiddleware requeued the failed handler
|
|
417
|
+
# rather than succeeding — undo the premature on_start mark
|
|
418
|
+
# so the retry redelivery is not dropped as a duplicate.
|
|
419
|
+
await self._cleanup_key_after_non_success_async(key)
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
# mark_policy == "on_success": check (no write) → handler → mark.
|
|
423
|
+
# See the sync variant above for the crash-safety rationale.
|
|
424
|
+
if self._local_is_dup(key):
|
|
425
|
+
logger.debug("Duplicate message detected in local cache (key=%s); acking and skipping", key)
|
|
426
|
+
if not message.is_settled:
|
|
427
|
+
await message.ack_async()
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
already_seen = bool(await self._redis.exists(key))
|
|
432
|
+
except Exception:
|
|
433
|
+
if not self._config.fallback_on_redis_error:
|
|
434
|
+
raise
|
|
435
|
+
self._record_fallback(message)
|
|
436
|
+
return await call_next(message)
|
|
437
|
+
|
|
438
|
+
if already_seen:
|
|
439
|
+
logger.debug("Duplicate message detected (key=%s); acking and skipping", key)
|
|
440
|
+
if not message.is_settled:
|
|
441
|
+
await message.ack_async()
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
result = await call_next(message)
|
|
445
|
+
if result is REQUEUED_FOR_RETRY:
|
|
446
|
+
# H8: an inner RetryMiddleware requeued the failed handler instead
|
|
447
|
+
# of it actually succeeding. Nothing was marked yet, so the retry
|
|
448
|
+
# redelivery (same dedup key) passes the dedup check — just skip
|
|
449
|
+
# the mark.
|
|
450
|
+
return result
|
|
451
|
+
# Handler succeeded — mark now. Never raise past this point, even with
|
|
452
|
+
# fallback_on_redis_error=False: raising would nack → redeliver → a
|
|
453
|
+
# GUARANTEED duplicate execution.
|
|
454
|
+
try:
|
|
455
|
+
await self._redis.set(key, "1", nx=True, ex=self._config.ttl)
|
|
456
|
+
except Exception:
|
|
457
|
+
self._record_fallback(message)
|
|
458
|
+
return result
|
|
459
|
+
self._local_mark(key)
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
# ── mark_policy == "claim" ────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def _is_in_flight(raw: Any) -> bool:
|
|
466
|
+
"""True when a GET result is a live in-flight claim.
|
|
467
|
+
|
|
468
|
+
``None`` (the key expired between the failed SET NX and this GET)
|
|
469
|
+
also counts — requeueing lets the redelivery claim it cleanly.
|
|
470
|
+
Any other value (``"completed"``, or the legacy ``"1"`` written by
|
|
471
|
+
on_success/on_start deployments) means completed.
|
|
472
|
+
"""
|
|
473
|
+
if raw is None:
|
|
474
|
+
return True
|
|
475
|
+
value = raw.decode() if isinstance(raw, bytes) else raw
|
|
476
|
+
return bool(value == _IN_FLIGHT)
|
|
477
|
+
|
|
478
|
+
def _handle_in_flight_duplicate_sync(self, message: RabbitMessage, key: str) -> None:
|
|
479
|
+
"""A concurrent copy hit another consumer's live claim (sync)."""
|
|
480
|
+
if self._config.on_in_flight == "ack_skip":
|
|
481
|
+
logger.debug("Duplicate of in-flight message (key=%s); acking and skipping", key)
|
|
482
|
+
if not message.is_settled:
|
|
483
|
+
message.ack()
|
|
484
|
+
return
|
|
485
|
+
# "nack_requeue" (default): the copy comes back and retries, so it is
|
|
486
|
+
# NOT lost if the claiming consumer dies mid-handler.
|
|
487
|
+
# ponytail: immediate requeue — the duplicate redelivers in a tight
|
|
488
|
+
# loop until the claim resolves, bounded by prefetch and the
|
|
489
|
+
# handler's duration; add a delay queue if that churn ever matters.
|
|
490
|
+
logger.debug("Duplicate of in-flight message (key=%s); nack-requeueing", key)
|
|
491
|
+
if not message.is_settled:
|
|
492
|
+
message.nack(requeue=True)
|
|
493
|
+
|
|
494
|
+
def _consume_claim_sync(
|
|
495
|
+
self,
|
|
496
|
+
call_next: Callable[[RabbitMessage], Any],
|
|
497
|
+
message: RabbitMessage,
|
|
498
|
+
key: str,
|
|
499
|
+
) -> Any:
|
|
500
|
+
"""Sync claim flow: atomically claim in-flight → handler → flip to
|
|
501
|
+
completed. Crash mid-handler lets the claim expire; the redelivery
|
|
502
|
+
then re-claims and processes."""
|
|
503
|
+
if self._local_is_dup(key):
|
|
504
|
+
logger.debug("Duplicate message detected in local cache (key=%s); acking and skipping", key)
|
|
505
|
+
if not message.is_settled:
|
|
506
|
+
message.ack()
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
claimed = bool(
|
|
511
|
+
self._redis.set(key, _IN_FLIGHT, nx=True, ex=self._config.processing_timeout)
|
|
512
|
+
)
|
|
513
|
+
except Exception:
|
|
514
|
+
if not self._config.fallback_on_redis_error:
|
|
515
|
+
raise
|
|
516
|
+
self._record_fallback(message)
|
|
517
|
+
return call_next(message)
|
|
518
|
+
|
|
519
|
+
if not claimed:
|
|
520
|
+
try:
|
|
521
|
+
raw = self._redis.get(key)
|
|
522
|
+
except Exception:
|
|
523
|
+
if not self._config.fallback_on_redis_error:
|
|
524
|
+
raise
|
|
525
|
+
self._record_fallback(message)
|
|
526
|
+
return call_next(message)
|
|
527
|
+
if self._is_in_flight(raw):
|
|
528
|
+
self._handle_in_flight_duplicate_sync(message, key)
|
|
529
|
+
return None
|
|
530
|
+
if self._config.store_results:
|
|
531
|
+
has_result, stored = _decode_stored_result(raw)
|
|
532
|
+
if has_result:
|
|
533
|
+
# F5: replay — returning the stored result makes the
|
|
534
|
+
# pipeline re-publish it to the route's result publisher /
|
|
535
|
+
# reply_to, so a duplicate (e.g. redelivered RPC request)
|
|
536
|
+
# gets a byte-identical answer without re-running the
|
|
537
|
+
# handler's side effects.
|
|
538
|
+
logger.debug("Duplicate (key=%s); replaying stored result", key)
|
|
539
|
+
if not message.is_settled:
|
|
540
|
+
message.ack()
|
|
541
|
+
return stored
|
|
542
|
+
logger.debug("Duplicate message detected (key=%s); acking and skipping", key)
|
|
543
|
+
if not message.is_settled:
|
|
544
|
+
message.ack()
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
result = call_next(message)
|
|
549
|
+
except Exception:
|
|
550
|
+
# Release the claim so a retry redelivery can re-claim immediately
|
|
551
|
+
# instead of waiting out processing_timeout.
|
|
552
|
+
self._cleanup_key_after_non_success(key)
|
|
553
|
+
raise
|
|
554
|
+
if result is REQUEUED_FOR_RETRY:
|
|
555
|
+
# H8: release the claim for the delayed retry redelivery.
|
|
556
|
+
self._cleanup_key_after_non_success(key)
|
|
557
|
+
return result
|
|
558
|
+
# Handler succeeded — flip the claim to completed with the full TTL.
|
|
559
|
+
# Never raise past this point (side effects are committed; raising
|
|
560
|
+
# would nack → redeliver → a guaranteed duplicate execution).
|
|
561
|
+
value = _COMPLETED
|
|
562
|
+
if self._config.store_results and result is not None:
|
|
563
|
+
encoded = _encode_completed_with_result(result, self._config.max_result_bytes)
|
|
564
|
+
if encoded is not None:
|
|
565
|
+
value = encoded
|
|
566
|
+
try:
|
|
567
|
+
self._redis.set(key, value, ex=self._config.ttl)
|
|
568
|
+
except Exception:
|
|
569
|
+
self._record_fallback(message)
|
|
570
|
+
return result
|
|
571
|
+
self._local_mark(key)
|
|
572
|
+
return result
|
|
573
|
+
|
|
574
|
+
async def _handle_in_flight_duplicate_async(self, message: RabbitMessage, key: str) -> None:
|
|
575
|
+
"""A concurrent copy hit another consumer's live claim (async)."""
|
|
576
|
+
if self._config.on_in_flight == "ack_skip":
|
|
577
|
+
logger.debug("Duplicate of in-flight message (key=%s); acking and skipping", key)
|
|
578
|
+
if not message.is_settled:
|
|
579
|
+
await message.ack_async()
|
|
580
|
+
return
|
|
581
|
+
logger.debug("Duplicate of in-flight message (key=%s); nack-requeueing", key)
|
|
582
|
+
if not message.is_settled:
|
|
583
|
+
await message.nack_async(requeue=True)
|
|
584
|
+
|
|
585
|
+
async def _consume_claim_async(
|
|
586
|
+
self,
|
|
587
|
+
call_next: Callable[[RabbitMessage], Awaitable[Any]],
|
|
588
|
+
message: RabbitMessage,
|
|
589
|
+
key: str,
|
|
590
|
+
) -> Any:
|
|
591
|
+
"""Async variant of :meth:`_consume_claim_sync`."""
|
|
592
|
+
if self._local_is_dup(key):
|
|
593
|
+
logger.debug("Duplicate message detected in local cache (key=%s); acking and skipping", key)
|
|
594
|
+
if not message.is_settled:
|
|
595
|
+
await message.ack_async()
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
claimed = bool(
|
|
600
|
+
await self._redis.set(key, _IN_FLIGHT, nx=True, ex=self._config.processing_timeout)
|
|
601
|
+
)
|
|
602
|
+
except Exception:
|
|
603
|
+
if not self._config.fallback_on_redis_error:
|
|
604
|
+
raise
|
|
605
|
+
self._record_fallback(message)
|
|
606
|
+
return await call_next(message)
|
|
607
|
+
|
|
608
|
+
if not claimed:
|
|
609
|
+
try:
|
|
610
|
+
raw = await self._redis.get(key)
|
|
611
|
+
except Exception:
|
|
612
|
+
if not self._config.fallback_on_redis_error:
|
|
613
|
+
raise
|
|
614
|
+
self._record_fallback(message)
|
|
615
|
+
return await call_next(message)
|
|
616
|
+
if self._is_in_flight(raw):
|
|
617
|
+
await self._handle_in_flight_duplicate_async(message, key)
|
|
618
|
+
return None
|
|
619
|
+
if self._config.store_results:
|
|
620
|
+
has_result, stored = _decode_stored_result(raw)
|
|
621
|
+
if has_result:
|
|
622
|
+
# F5: replay the stored result -- see the sync counterpart.
|
|
623
|
+
logger.debug("Duplicate (key=%s); replaying stored result", key)
|
|
624
|
+
if not message.is_settled:
|
|
625
|
+
await message.ack_async()
|
|
626
|
+
return stored
|
|
627
|
+
logger.debug("Duplicate message detected (key=%s); acking and skipping", key)
|
|
628
|
+
if not message.is_settled:
|
|
629
|
+
await message.ack_async()
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
result = await call_next(message)
|
|
634
|
+
except Exception:
|
|
635
|
+
await self._cleanup_key_after_non_success_async(key)
|
|
636
|
+
raise
|
|
637
|
+
if result is REQUEUED_FOR_RETRY:
|
|
638
|
+
await self._cleanup_key_after_non_success_async(key)
|
|
639
|
+
return result
|
|
640
|
+
value = _COMPLETED
|
|
641
|
+
if self._config.store_results and result is not None:
|
|
642
|
+
encoded = _encode_completed_with_result(result, self._config.max_result_bytes)
|
|
643
|
+
if encoded is not None:
|
|
644
|
+
value = encoded
|
|
645
|
+
try:
|
|
646
|
+
await self._redis.set(key, value, ex=self._config.ttl)
|
|
647
|
+
except Exception:
|
|
648
|
+
self._record_fallback(message)
|
|
649
|
+
return result
|
|
650
|
+
self._local_mark(key)
|
|
651
|
+
return result
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""ErrorClassifierMiddleware — pluggable error classification.
|
|
2
|
+
|
|
3
|
+
NOT a standalone middleware in the chain. Used internally by RetryMiddleware.
|
|
4
|
+
Wraps core/errors.py classify_error() with configurable per-route overrides.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from rabbitkit.core.errors import ErrorPredicate, classify_error
|
|
12
|
+
from rabbitkit.core.types import ClassifiedError, ErrorSeverity
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ErrorClassifierMiddleware:
|
|
16
|
+
"""Error classification component used by RetryMiddleware.
|
|
17
|
+
|
|
18
|
+
Pluggable via predicates: Callable[[BaseException], bool | None]
|
|
19
|
+
True=transient, False=permanent, None=no opinion (fall through).
|
|
20
|
+
|
|
21
|
+
unknown_policy configurable (default=PERMANENT). See Contract 7.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
predicates: Sequence[ErrorPredicate] = (),
|
|
28
|
+
unknown_policy: ErrorSeverity = ErrorSeverity.PERMANENT,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._predicates = list(predicates)
|
|
31
|
+
self._unknown_policy = unknown_policy
|
|
32
|
+
|
|
33
|
+
def classify(self, exc: BaseException) -> ClassifiedError:
|
|
34
|
+
"""Classify an exception's severity."""
|
|
35
|
+
return classify_error(
|
|
36
|
+
exc,
|
|
37
|
+
predicates=self._predicates,
|
|
38
|
+
unknown_policy=self._unknown_policy,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def unknown_policy(self) -> ErrorSeverity:
|
|
43
|
+
return self._unknown_policy
|