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
rabbitkit/dlq.py ADDED
@@ -0,0 +1,320 @@
1
+ """DLQ Inspector — peek, replay, and purge dead-letter queues.
2
+
3
+ Provides inspection and recovery tools for messages stuck in DLQs.
4
+
5
+ **Operational realism:**
6
+ - ``peek()`` returns materialized snapshots, not live references
7
+ - ``peek()`` may affect message ordering (basic.get + requeue changes position)
8
+ - ``replay()`` preserves original headers (pass ``reset_retry_count=True`` to
9
+ grant the replayed message a fresh retry ladder)
10
+ - ``replay()`` acks a DLQ original only after the republish outcome is OK;
11
+ failed republishes are nack-requeued so they stay on the DLQ
12
+ - ``purge()`` is immediate and unfiltered — use ``replay()`` for selective recovery
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from collections.abc import Callable
19
+ from typing import Any
20
+
21
+ from rabbitkit.core.message import RabbitMessage
22
+ from rabbitkit.core.types import MessageEnvelope
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Matches RetryConfig.retry_header's default. If you customized retry_header,
27
+ # strip your header via a predicate/pre-processing step instead.
28
+ _RETRY_COUNT_HEADER = "x-rabbitkit-retry-count"
29
+
30
+
31
+ class ReplayResult(int):
32
+ """Replay report that IS the replayed count (int-compatible, so existing
33
+ ``count = inspector.replay(...)`` callers keep working), with extras:
34
+
35
+ - ``failed``: messages whose republish outcome was not OK — they were
36
+ nack-requeued and REMAIN ON THE DLQ.
37
+ - ``requeued``: non-matching messages returned to the DLQ (predicate
38
+ returned False).
39
+ """
40
+
41
+ failed: int
42
+ requeued: int
43
+
44
+ def __new__(cls, replayed: int, failed: int = 0, requeued: int = 0) -> ReplayResult:
45
+ obj = super().__new__(cls, replayed)
46
+ obj.failed = failed
47
+ obj.requeued = requeued
48
+ return obj
49
+
50
+ def __repr__(self) -> str:
51
+ return f"ReplayResult(replayed={int(self)}, failed={self.failed}, requeued={self.requeued})"
52
+
53
+
54
+ class DLQInspector:
55
+ """Dead-letter queue inspection and replay.
56
+
57
+ Accepts a transport that supports ``basic_get``, ``publish``,
58
+ ``purge_queue`` methods. Works with both sync and async transports.
59
+
60
+ Usage::
61
+
62
+ inspector = DLQInspector(transport)
63
+
64
+ # Peek at messages without consuming them
65
+ messages = inspector.peek("orders-queue.dlq", limit=5)
66
+
67
+ # Replay matching messages back to source queue
68
+ count = inspector.replay(
69
+ "orders-queue.dlq",
70
+ predicate=lambda msg: msg.headers.get("x-error") == "timeout",
71
+ target_queue="orders-queue",
72
+ )
73
+
74
+ # Purge entire DLQ
75
+ count = inspector.purge("orders-queue.dlq")
76
+ """
77
+
78
+ def __init__(self, transport: Any) -> None:
79
+ self._transport = transport
80
+
81
+ # ── Sync methods ─────────────────────────────────────────────────────
82
+
83
+ def peek(self, queue: str, limit: int = 10) -> list[RabbitMessage]:
84
+ """Fetch up to ``limit`` messages from the queue, then requeue them.
85
+
86
+ Messages are nacked with ``requeue=True`` so they return to the queue.
87
+ Ordering may change after this operation.
88
+
89
+ Returns a list of message snapshots.
90
+ """
91
+ messages: list[RabbitMessage] = []
92
+
93
+ for _ in range(limit):
94
+ msg = self._transport.basic_get(queue)
95
+ if msg is None:
96
+ break
97
+ messages.append(msg)
98
+
99
+ # Requeue all peeked messages
100
+ for msg in messages:
101
+ if not msg.is_settled:
102
+ msg.nack(requeue=True)
103
+
104
+ return messages
105
+
106
+ @staticmethod
107
+ def _build_replay_envelope(
108
+ msg: RabbitMessage,
109
+ target_queue: str | None,
110
+ target_exchange: str | None,
111
+ reset_retry_count: bool,
112
+ ) -> MessageEnvelope:
113
+ """Build the republish envelope for one DLQ message.
114
+
115
+ ``mandatory=True`` so an unroutable target comes back as a
116
+ ``RETURNED`` outcome instead of being broker-confirmed into the void.
117
+ """
118
+ rk = target_queue or msg.headers.get("x-rabbitkit-original-queue", msg.routing_key)
119
+ headers = dict(msg.headers)
120
+ if reset_retry_count:
121
+ headers.pop(_RETRY_COUNT_HEADER, None)
122
+ return MessageEnvelope(
123
+ routing_key=rk,
124
+ body=msg.body,
125
+ exchange=target_exchange if target_exchange is not None else "",
126
+ headers=headers,
127
+ message_id=msg.message_id or "",
128
+ correlation_id=msg.correlation_id,
129
+ content_type=msg.content_type or "application/octet-stream",
130
+ content_encoding=msg.content_encoding,
131
+ # Preserve the remaining original message properties -- these used
132
+ # to be silently dropped on replay, e.g. a priority-queue message
133
+ # lost its priority, and an RPC request's reply_to/type/app_id/
134
+ # user_id never survived the replay for the reply to route back.
135
+ reply_to=msg.reply_to,
136
+ priority=msg.priority,
137
+ expiration=msg.expiration,
138
+ type=msg.type,
139
+ app_id=msg.app_id,
140
+ user_id=msg.user_id,
141
+ mandatory=True,
142
+ )
143
+
144
+ def replay(
145
+ self,
146
+ queue: str,
147
+ predicate: Callable[[RabbitMessage], bool] | None = None,
148
+ target_queue: str | None = None,
149
+ target_exchange: str | None = None,
150
+ *,
151
+ reset_retry_count: bool = False,
152
+ ) -> ReplayResult:
153
+ """Replay messages from the DLQ.
154
+
155
+ Fetches messages, applies optional predicate filter, publishes
156
+ matching messages to the target, and acks each original **only after
157
+ its republish outcome is OK**. A failed republish (NACKED / TIMEOUT /
158
+ RETURNED / ERROR) is nack-requeued, so the message stays on the DLQ
159
+ instead of being lost.
160
+
161
+ Non-matching messages are nacked with ``requeue=True``.
162
+
163
+ Args:
164
+ queue: Source DLQ to replay from.
165
+ predicate: Optional filter — only replay messages where
166
+ predicate returns True. All messages replayed if None.
167
+ target_queue: Target queue routing key. Defaults to the
168
+ original queue from message headers.
169
+ target_exchange: Target exchange. Defaults to "".
170
+ reset_retry_count: Strip the ``x-rabbitkit-retry-count`` header
171
+ so the replayed message gets a fresh retry ladder. Default
172
+ False preserves headers verbatim — meaning a previously
173
+ max-retried message is terminal after ONE failed attempt and
174
+ returns to the DLQ.
175
+
176
+ Returns:
177
+ :class:`ReplayResult` — int-compatible replayed count, with
178
+ ``.failed`` (left on the DLQ) and ``.requeued`` (non-matching).
179
+
180
+ Loop Engineering Review, Reliability: a non-matching or
181
+ failed-publish message is **not** nacked (requeued) until after this
182
+ method's fetch loop has fully exhausted the queue. ``basic_get`` has
183
+ no natural "already seen this delivery" tracking of its own -- if
184
+ such a message were requeued immediately, and nothing else is
185
+ consuming from this queue, the very next ``basic_get`` call in this
186
+ same loop could immediately re-fetch that exact message, forever.
187
+ Held-but-unsettled messages are invisible to further ``basic_get``
188
+ calls (the broker still considers them delivered-but-unacked), so
189
+ deferring the nack until after the loop truly exits guarantees
190
+ termination regardless of how many messages the predicate rejects or
191
+ the publisher fails.
192
+ """
193
+ replayed = 0
194
+ held_for_requeue: list[RabbitMessage] = []
195
+ failed = 0
196
+ requeued = 0
197
+
198
+ while True:
199
+ msg = self._transport.basic_get(queue)
200
+ if msg is None:
201
+ break
202
+
203
+ # Apply predicate filter -- hold, don't nack yet (see docstring).
204
+ if predicate is not None and not predicate(msg):
205
+ held_for_requeue.append(msg)
206
+ requeued += 1
207
+ continue
208
+
209
+ envelope = self._build_replay_envelope(msg, target_queue, target_exchange, reset_retry_count)
210
+ outcome = self._transport.publish(envelope)
211
+ if outcome is not None and not outcome.ok:
212
+ # Republish failed — DO NOT ack, or the message is lost
213
+ # forever. Hold for nack-requeue so it stays on the DLQ.
214
+ logger.error(
215
+ "DLQ replay publish failed (status=%s); message stays on %r: routing_key=%s message_id=%s",
216
+ getattr(outcome, "status", "unknown"),
217
+ queue,
218
+ envelope.routing_key,
219
+ envelope.message_id,
220
+ )
221
+ held_for_requeue.append(msg)
222
+ failed += 1
223
+ continue
224
+
225
+ # Ack the original — it is safely republished now
226
+ if not msg.is_settled:
227
+ msg.ack()
228
+
229
+ replayed += 1
230
+
231
+ # The fetch loop is done (basic_get returned None) -- now it's safe
232
+ # to requeue held messages; this loop can no longer re-fetch them.
233
+ for msg in held_for_requeue:
234
+ if not msg.is_settled:
235
+ msg.nack(requeue=True)
236
+
237
+ return ReplayResult(replayed, failed=failed, requeued=requeued)
238
+
239
+ def purge(self, queue: str) -> int:
240
+ """Purge all messages from the queue.
241
+
242
+ Returns the number of messages purged.
243
+ """
244
+ return int(self._transport.purge_queue(queue))
245
+
246
+ # ── Async methods ────────────────────────────────────────────────────
247
+
248
+ async def peek_async(self, queue: str, limit: int = 10) -> list[RabbitMessage]:
249
+ """Async variant of ``peek``."""
250
+ messages: list[RabbitMessage] = []
251
+
252
+ for _ in range(limit):
253
+ msg = await self._transport.basic_get(queue)
254
+ if msg is None:
255
+ break
256
+ messages.append(msg)
257
+
258
+ # Requeue all peeked messages
259
+ for msg in messages:
260
+ if not msg.is_settled:
261
+ await msg.nack_async(requeue=True)
262
+
263
+ return messages
264
+
265
+ async def replay_async(
266
+ self,
267
+ queue: str,
268
+ predicate: Callable[[RabbitMessage], bool] | None = None,
269
+ target_queue: str | None = None,
270
+ target_exchange: str | None = None,
271
+ *,
272
+ reset_retry_count: bool = False,
273
+ ) -> ReplayResult:
274
+ """Async variant of ``replay`` -- see its docstring for the
275
+ outcome-checked ack, ``reset_retry_count``, and why non-matching /
276
+ failed messages are held, not nacked, until the fetch loop has fully
277
+ exhausted the queue (termination guarantee)."""
278
+ replayed = 0
279
+ held_for_requeue: list[RabbitMessage] = []
280
+ failed = 0
281
+ requeued = 0
282
+
283
+ while True:
284
+ msg = await self._transport.basic_get(queue)
285
+ if msg is None:
286
+ break
287
+
288
+ if predicate is not None and not predicate(msg):
289
+ held_for_requeue.append(msg)
290
+ requeued += 1
291
+ continue
292
+
293
+ envelope = self._build_replay_envelope(msg, target_queue, target_exchange, reset_retry_count)
294
+ outcome = await self._transport.publish(envelope)
295
+ if outcome is not None and not outcome.ok:
296
+ logger.error(
297
+ "DLQ replay publish failed (status=%s); message stays on %r: routing_key=%s message_id=%s",
298
+ getattr(outcome, "status", "unknown"),
299
+ queue,
300
+ envelope.routing_key,
301
+ envelope.message_id,
302
+ )
303
+ held_for_requeue.append(msg)
304
+ failed += 1
305
+ continue
306
+
307
+ if not msg.is_settled:
308
+ await msg.ack_async()
309
+
310
+ replayed += 1
311
+
312
+ for msg in held_for_requeue:
313
+ if not msg.is_settled:
314
+ await msg.nack_async(requeue=True)
315
+
316
+ return ReplayResult(replayed, failed=failed, requeued=requeued)
317
+
318
+ async def purge_async(self, queue: str) -> int:
319
+ """Async variant of ``purge``."""
320
+ return int(await self._transport.purge_queue(queue))
@@ -0,0 +1,50 @@
1
+ """rabbitkit.experimental — features under active development.
2
+
3
+ These APIs are NOT covered by the stability guarantee. They may change or
4
+ be removed in any release without a deprecation period.
5
+
6
+ Stable APIs are in the top-level ``rabbitkit`` package.
7
+
8
+ Experimental features:
9
+ - RPC (tight coupling, use with care)
10
+ - Dashboard (web UI for local development)
11
+ - Stream queues
12
+ - Distributed locking
13
+ - Message signing
14
+ - Result backends
15
+
16
+ Usage::
17
+
18
+ from rabbitkit.experimental import AsyncRPCClient, RPCClient
19
+ from rabbitkit.experimental import create_dashboard_app
20
+ from rabbitkit.experimental import DistributedLock, RedisLock
21
+ from rabbitkit.experimental import SigningMiddleware
22
+ from rabbitkit.experimental import RedisResultBackend
23
+ """
24
+
25
+ from rabbitkit.dashboard.app import create_dashboard_app
26
+ from rabbitkit.locking import DistributedLock, LockMiddleware, RedisLock
27
+ from rabbitkit.middleware.signing import InvalidSignatureError, SigningConfig, SigningMiddleware
28
+ from rabbitkit.results.backend import RedisResultBackend, ResultBackend
29
+ from rabbitkit.results.middleware import ResultMiddleware
30
+ from rabbitkit.rpc import AsyncRPCClient, RPCClient, RPCTimeoutError
31
+ from rabbitkit.streams import StreamConsumerConfig, StreamOffset, StreamOffsetType
32
+
33
+ __all__ = [
34
+ "AsyncRPCClient",
35
+ "DistributedLock",
36
+ "InvalidSignatureError",
37
+ "LockMiddleware",
38
+ "RPCClient",
39
+ "RPCTimeoutError",
40
+ "RedisLock",
41
+ "RedisResultBackend",
42
+ "ResultBackend",
43
+ "ResultMiddleware",
44
+ "SigningConfig",
45
+ "SigningMiddleware",
46
+ "StreamConsumerConfig",
47
+ "StreamOffset",
48
+ "StreamOffsetType",
49
+ "create_dashboard_app",
50
+ ]
rabbitkit/fastapi.py ADDED
@@ -0,0 +1,91 @@
1
+ """FastAPI integration — lifespan context manager for rabbitkit.
2
+
3
+ Thin module that wires broker startup/shutdown to FastAPI's lifespan.
4
+
5
+ Usage::
6
+
7
+ from fastapi import FastAPI
8
+ from rabbitkit.fastapi import rabbitkit_lifespan
9
+
10
+ app = FastAPI(lifespan=rabbitkit_lifespan(broker=broker, rabbit_app=rabbit_app))
11
+
12
+ Or as a decorator-style::
13
+
14
+ @asynccontextmanager
15
+ async def lifespan(app):
16
+ async with rabbitkit_lifespan(broker=broker):
17
+ yield
18
+
19
+ app = FastAPI(lifespan=lifespan)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import logging
26
+ from collections.abc import AsyncIterator
27
+ from contextlib import asynccontextmanager
28
+ from typing import Any
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @asynccontextmanager
34
+ async def rabbitkit_lifespan(
35
+ app: Any = None,
36
+ *,
37
+ broker: Any | None = None,
38
+ rabbit_app: Any | None = None,
39
+ ) -> AsyncIterator[None]:
40
+ """Async context manager that starts/stops rabbitkit components.
41
+
42
+ Suitable for use as FastAPI's ``lifespan`` parameter or standalone.
43
+
44
+ Start order: rabbit_app.start_async() → broker.start() / broker.start_async()
45
+ Stop order (in finally): broker.stop() / broker.stop_async() → rabbit_app.stop_async()
46
+
47
+ Duck-types sync vs async: if the method is a coroutine, it is awaited.
48
+
49
+ Args:
50
+ app: FastAPI app instance (passed by FastAPI lifespan protocol, may be None).
51
+ broker: Optional rabbitkit broker (SyncBroker or AsyncBroker).
52
+ rabbit_app: Optional RabbitApp for lifecycle hooks.
53
+ """
54
+ try:
55
+ # Start rabbit_app first (startup hooks)
56
+ if rabbit_app is not None:
57
+ if hasattr(rabbit_app, "start_async"):
58
+ await rabbit_app.start_async()
59
+ elif hasattr(rabbit_app, "start"):
60
+ result = rabbit_app.start()
61
+ if asyncio.iscoroutine(result):
62
+ await result
63
+
64
+ # Start broker
65
+ if broker is not None:
66
+ if asyncio.iscoroutinefunction(getattr(broker, "start", None)):
67
+ await broker.start()
68
+ elif hasattr(broker, "start"):
69
+ broker.start()
70
+
71
+ logger.info("rabbitkit lifespan started")
72
+ yield
73
+
74
+ finally:
75
+ # Stop broker first
76
+ if broker is not None:
77
+ if asyncio.iscoroutinefunction(getattr(broker, "stop", None)):
78
+ await broker.stop()
79
+ elif hasattr(broker, "stop"):
80
+ broker.stop()
81
+
82
+ # Stop rabbit_app (shutdown hooks)
83
+ if rabbit_app is not None:
84
+ if hasattr(rabbit_app, "stop_async"):
85
+ await rabbit_app.stop_async()
86
+ elif hasattr(rabbit_app, "stop"):
87
+ result = rabbit_app.stop()
88
+ if asyncio.iscoroutine(result):
89
+ await result
90
+
91
+ logger.info("rabbitkit lifespan stopped")