rabbitkit 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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