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,131 @@
1
+ """Circuit breaker middleware — wraps handler execution and publish operations.
2
+
3
+ Uses any circuit-breaker implementation that satisfies
4
+ CircuitBreakerProtocol / AsyncCircuitBreakerProtocol.
5
+
6
+ When the circuit is open, operations fail fast with CircuitBreakerOpenError
7
+ without hitting the broker, preventing cascade failures.
8
+
9
+ Lazy/no-op pattern: if no circuit breaker is provided, middleware is a passthrough.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from collections.abc import Awaitable, Callable
16
+ from typing import Any
17
+
18
+ from rabbitkit.core.message import RabbitMessage
19
+ from rabbitkit.core.types import MessageEnvelope
20
+ from rabbitkit.middleware.base import BaseMiddleware
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class CircuitBreakerOpenError(Exception):
26
+ """Raised when circuit breaker is open and rejects an operation."""
27
+
28
+
29
+ class CircuitBreakerMiddleware(BaseMiddleware):
30
+ """Wraps handler execution and publish operations with circuit breaker.
31
+
32
+ If circuit breaker is not provided (None), all operations pass through
33
+ without any wrapping (no-op mode).
34
+
35
+ Usage::
36
+
37
+ # any CircuitBreakerProtocol-compatible implementation, e.g. pybreaker
38
+ cb = MyCircuitBreaker(name="rabbitmq", fail_max=5, reset_timeout=60)
39
+ middleware = CircuitBreakerMiddleware(circuit_breaker=cb)
40
+
41
+ # Or with separate publish CB:
42
+ middleware = CircuitBreakerMiddleware(
43
+ circuit_breaker=consume_cb,
44
+ publish_circuit_breaker=publish_cb,
45
+ )
46
+
47
+ Args:
48
+ circuit_breaker: Circuit breaker for consume operations (handler execution).
49
+ Must satisfy CircuitBreakerProtocol. None for no-op.
50
+ publish_circuit_breaker: Circuit breaker for publish operations.
51
+ If None, uses circuit_breaker for both. If circuit_breaker is also
52
+ None, publish operations pass through.
53
+ async_circuit_breaker: Async circuit breaker for consume operations.
54
+ Must satisfy AsyncCircuitBreakerProtocol. If None, falls back to
55
+ wrapping the sync circuit_breaker in async context.
56
+ async_publish_circuit_breaker: Async circuit breaker for publish operations.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ circuit_breaker: Any | None = None,
62
+ publish_circuit_breaker: Any | None = None,
63
+ *,
64
+ async_circuit_breaker: Any | None = None,
65
+ async_publish_circuit_breaker: Any | None = None,
66
+ ) -> None:
67
+ self._cb = circuit_breaker
68
+ self._publish_cb = publish_circuit_breaker or circuit_breaker
69
+ self._async_cb = async_circuit_breaker
70
+ self._async_publish_cb = async_publish_circuit_breaker or async_circuit_breaker
71
+
72
+ # ── Consume-side ──────────────────────────────────────────────────────
73
+
74
+ def consume_scope(
75
+ self,
76
+ call_next: Callable[[RabbitMessage], Any],
77
+ message: RabbitMessage,
78
+ ) -> Any:
79
+ """Wrap handler execution with circuit breaker (sync)."""
80
+ if self._cb is None:
81
+ return call_next(message)
82
+ return self._cb.call(call_next, message)
83
+
84
+ async def consume_scope_async(
85
+ self,
86
+ call_next: Callable[[RabbitMessage], Awaitable[Any]],
87
+ message: RabbitMessage,
88
+ ) -> Any:
89
+ """Wrap handler execution with circuit breaker (async)."""
90
+ if self._async_cb is not None:
91
+ return await self._async_cb.call_async(call_next, message)
92
+ if self._cb is not None:
93
+ # Sync CB cannot safely wrap an async handler — the sync call()
94
+ # would receive a coroutine object, not the result. Raise at call
95
+ # time so the misconfiguration surfaces immediately rather than
96
+ # silently skipping every handler invocation.
97
+ raise TypeError(
98
+ "CircuitBreakerMiddleware: async handler requires "
99
+ "async_circuit_breaker=. Providing only a sync circuit_breaker "
100
+ "with an async broker is not supported (the handler would never "
101
+ "run). Pass async_circuit_breaker= instead."
102
+ )
103
+ return await call_next(message)
104
+
105
+ # ── Publish-side ──────────────────────────────────────────────────────
106
+
107
+ def publish_scope(
108
+ self,
109
+ call_next: Callable[[MessageEnvelope], Any],
110
+ envelope: MessageEnvelope,
111
+ ) -> Any:
112
+ """Wrap publish with circuit breaker (sync)."""
113
+ if self._publish_cb is None:
114
+ return call_next(envelope)
115
+ return self._publish_cb.call(call_next, envelope)
116
+
117
+ async def publish_scope_async(
118
+ self,
119
+ call_next: Callable[[MessageEnvelope], Awaitable[Any]],
120
+ envelope: MessageEnvelope,
121
+ ) -> Any:
122
+ """Wrap publish with circuit breaker (async)."""
123
+ if self._async_publish_cb is not None:
124
+ return await self._async_publish_cb.call_async(call_next, envelope)
125
+ if self._publish_cb is not None:
126
+ raise TypeError(
127
+ "CircuitBreakerMiddleware: async publish requires "
128
+ "async_publish_circuit_breaker=. Providing only a sync "
129
+ "publish_circuit_breaker with an async broker is not supported."
130
+ )
131
+ return await call_next(envelope)
@@ -0,0 +1,267 @@
1
+ """CompressionMiddleware — envelope/body transformation.
2
+
3
+ Publish side: serialize → compress body → set content_encoding header → transport.publish
4
+ Consume side: transport delivers → check content_encoding → decompress body → deserialize
5
+
6
+ Combining with SigningMiddleware (H7): list ``CompressionMiddleware`` BEFORE
7
+ ``SigningMiddleware`` in ``middlewares=[...]`` — ``[CompressionMiddleware,
8
+ SigningMiddleware]``, compression outer. This is required: SigningMiddleware's
9
+ signature covers ``content_encoding``, a field this middleware's
10
+ ``publish_scope`` is what actually sets, so signing must run AFTER
11
+ compression on publish to sign the final value — the reverse order signs
12
+ ``content_encoding=None`` and always fails verification. ``on_receive``
13
+ (where decompression happens) runs in the REVERSE of registration order on
14
+ consume — the mirror of ``publish_scope``'s outer→inner composition — so with
15
+ the correct registration order, verification runs before decompression,
16
+ matching compress-then-sign on publish. See
17
+ ``rabbitkit.middleware.signing``'s module docstring and
18
+ ``HandlerPipeline._run_consume_sync``'s docstring for the full explanation. A
19
+ decompression failure in ``on_receive`` is not retry-eligible — it settles
20
+ per the route's ``AckPolicy`` directly, bypassing any ``RetryMiddleware`` on
21
+ the route.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import gzip
28
+ import io
29
+ import logging
30
+ import threading
31
+ import zlib
32
+ from typing import Any
33
+
34
+ from rabbitkit.core.config import CompressionConfig
35
+ from rabbitkit.core.message import RabbitMessage
36
+ from rabbitkit.core.types import MessageEnvelope
37
+ from rabbitkit.middleware.base import BaseMiddleware
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Bodies larger than this are decompressed in a worker thread (async path) to
42
+ # avoid blocking the event loop. The size threshold is a *secondary* trigger —
43
+ # decompression is data-dependent, so a small-on-the-wire bomb still gets
44
+ # offloaded whenever content_encoding is set.
45
+
46
+ # Streaming decompression chunk size.
47
+ _CHUNK = 64 * 1024
48
+
49
+
50
+ def _get_zstd() -> Any:
51
+ """Lazy import of zstandard."""
52
+ try:
53
+ import zstandard
54
+
55
+ return zstandard
56
+ except ImportError:
57
+ raise ImportError(
58
+ "zstandard is required for zstd compression. Install it with: pip install rabbitkit[compression]"
59
+ ) from None
60
+
61
+
62
+ class CompressionMiddleware(BaseMiddleware):
63
+ """Envelope/body transformation for compression.
64
+
65
+ Operates on MessageEnvelope (publish) and RabbitMessage.body (consume).
66
+ NOT a handler-wrapping middleware — transforms data before/after serialize.
67
+
68
+ zstd contexts are **not** thread-safe, so a ``threading.local()`` holds a
69
+ per-thread ``ZstdCompressor``/``ZstdDecompressor`` (created lazily), giving
70
+ concurrent workers isolated contexts without a global lock. The
71
+ decompressed size is capped via **streaming** decompression that aborts as
72
+ soon as the running total exceeds ``max_decompressed_size`` (zip-bomb guard).
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ config: CompressionConfig | None = None,
78
+ *,
79
+ max_decompressed_size: int = 64 * 1024 * 1024,
80
+ ) -> None:
81
+ self._config = config or CompressionConfig()
82
+ self._max_decompressed_size = max_decompressed_size
83
+ # Per-thread zstd contexts (zstandard contexts are not thread-safe).
84
+ self._zstd_local = threading.local()
85
+
86
+ def _get_cctx(self) -> Any:
87
+ cctx = getattr(self._zstd_local, "cctx", None)
88
+ if cctx is None:
89
+ zstd = _get_zstd()
90
+ cctx = zstd.ZstdCompressor(level=self._config.level)
91
+ self._zstd_local.cctx = cctx
92
+ return cctx
93
+
94
+ def _get_dctx(self) -> Any:
95
+ dctx = getattr(self._zstd_local, "dctx", None)
96
+ if dctx is None:
97
+ zstd = _get_zstd()
98
+ try:
99
+ dctx = zstd.ZstdDecompressor(max_window_size=self._max_decompressed_size)
100
+ except TypeError: # older zstandard has no max_window_size
101
+ dctx = zstd.ZstdDecompressor()
102
+ self._zstd_local.dctx = dctx
103
+ return dctx
104
+
105
+ def compress(self, data: bytes) -> tuple[bytes, str | None]:
106
+ """Compress data if above threshold.
107
+
108
+ Returns (compressed_data, content_encoding) or (original_data, None).
109
+ """
110
+ if len(data) < self._config.threshold:
111
+ return data, None
112
+
113
+ algorithm = self._config.algorithm
114
+ if algorithm == "gzip":
115
+ compressed = gzip.compress(data, compresslevel=self._config.level)
116
+ return compressed, "gzip"
117
+ elif algorithm == "zstd":
118
+ cctx = self._get_cctx()
119
+ compressed = cctx.compress(data)
120
+ return compressed, "zstd"
121
+ else:
122
+ raise ValueError(f"Unknown compression algorithm: {algorithm}")
123
+
124
+ def _decompress_gzip_streaming(self, data: bytes) -> bytes:
125
+ """Streaming gzip decompression that aborts at the size cap.
126
+
127
+ Uses ``zlib.decompressobj(16 + MAX_WBITS)`` and feeds ``data`` through
128
+ ``decompress(..., _CHUNK)``. Because ``max_length`` limits *output*
129
+ (not input consumed), the unconsumed input is re-fed via
130
+ ``unconsumed_tail`` each iteration; the running total is checked every
131
+ chunk — a zip bomb raises before a huge allocation is materialised.
132
+ """
133
+ decomp = zlib.decompressobj(16 + zlib.MAX_WBITS)
134
+ out = bytearray()
135
+ tail: bytes = data
136
+ while True:
137
+ chunk = decomp.decompress(tail, _CHUNK)
138
+ out += chunk
139
+ if len(out) > self._max_decompressed_size:
140
+ raise ValueError(
141
+ f"Decompressed size ({len(out)}) exceeds max_decompressed_size ({self._max_decompressed_size})"
142
+ )
143
+ tail = decomp.unconsumed_tail
144
+ if decomp.eof:
145
+ break
146
+ if not chunk and not tail:
147
+ # No output and no input left to feed — avoid spinning.
148
+ break
149
+ out += decomp.flush()
150
+ if len(out) > self._max_decompressed_size:
151
+ raise ValueError(
152
+ f"Decompressed size ({len(out)}) exceeds max_decompressed_size ({self._max_decompressed_size})"
153
+ )
154
+ return bytes(out)
155
+
156
+ def _decompress_zstd_streaming(self, data: bytes) -> bytes:
157
+ """Streaming zstd decompression that aborts at the size cap.
158
+
159
+ ``max_window_size`` on the decompressor rejects frames whose window
160
+ exceeds the cap (raising ``ZstdError`` before allocating); the streaming
161
+ ``read`` loop enforces the running-total cap for high-ratio payloads. Both
162
+ are surfaced as ``ValueError`` so callers see a single, consistent
163
+ zip-bomb guard.
164
+ """
165
+ zstd = _get_zstd()
166
+ dctx = self._get_dctx()
167
+ reader = dctx.stream_reader(io.BytesIO(data))
168
+ out = bytearray()
169
+ try:
170
+ while True:
171
+ chunk = reader.read(_CHUNK)
172
+ if not chunk:
173
+ break
174
+ out += chunk
175
+ if len(out) > self._max_decompressed_size:
176
+ raise ValueError(
177
+ f"Decompressed size ({len(out)}) exceeds max_decompressed_size ({self._max_decompressed_size})"
178
+ )
179
+ except zstd.ZstdError as exc:
180
+ # Frame too large for the configured window, or other decode error —
181
+ # treat as a zip-bomb / oversized-payload rejection.
182
+ raise ValueError(
183
+ f"Decompressed size exceeds max_decompressed_size ({self._max_decompressed_size})"
184
+ ) from exc
185
+ return bytes(out)
186
+
187
+ def decompress(self, data: bytes, content_encoding: str | None) -> bytes:
188
+ """Decompress data based on content_encoding header.
189
+
190
+ Raises ``ValueError`` as soon as the running decompressed total exceeds
191
+ ``max_decompressed_size`` (streaming zip-bomb guard).
192
+ """
193
+ if content_encoding is None:
194
+ return data
195
+
196
+ if content_encoding == "gzip":
197
+ return self._decompress_gzip_streaming(data)
198
+ elif content_encoding == "zstd":
199
+ return self._decompress_zstd_streaming(data)
200
+ else:
201
+ logger.warning("Unknown content_encoding: %s, returning raw data", content_encoding)
202
+ return data
203
+
204
+ def on_receive(self, message: RabbitMessage) -> None:
205
+ """Decompress incoming message body if content_encoding is set."""
206
+ if message.content_encoding:
207
+ message.body = self.decompress(message.body, message.content_encoding)
208
+
209
+ async def on_receive_async(self, message: RabbitMessage) -> None:
210
+ """Async variant — offloads decompression to a worker thread.
211
+
212
+ Offload whenever there is decompression work to do (content_encoding is
213
+ set) OR the body is large, to avoid inline-decompressing a
214
+ small-on-the-wire bomb on the event loop.
215
+ """
216
+ if not message.content_encoding:
217
+ return
218
+ # content_encoding is set: always offload to a worker thread. A
219
+ # small-on-the-wire zip bomb can still produce a large decompressed
220
+ # body, so never inline-decompress on the event loop.
221
+ message.body = await asyncio.to_thread(self.decompress, message.body, message.content_encoding)
222
+
223
+ def publish_scope(self, call_next: Any, envelope: MessageEnvelope) -> Any:
224
+ """Compress the envelope body before publishing (sync).
225
+
226
+ C4: without this override, ``transform_envelope`` had no caller —
227
+ attaching ``CompressionMiddleware`` to a route or a broker's
228
+ ``middlewares=[...]`` compressed nothing.
229
+ """
230
+ return call_next(self.transform_envelope(envelope))
231
+
232
+ async def publish_scope_async(self, call_next: Any, envelope: MessageEnvelope) -> Any:
233
+ """Async variant — compress the envelope body before publishing.
234
+
235
+ Compression runs inline (not offloaded to a worker thread) — unlike
236
+ ``on_receive_async``'s decompression, the body size here is caller-
237
+ controlled, not attacker-controlled, so there is no zip-bomb-style
238
+ amplification risk to guard against by offloading.
239
+ """
240
+ return await call_next(self.transform_envelope(envelope))
241
+
242
+ def transform_envelope(self, envelope: MessageEnvelope) -> MessageEnvelope:
243
+ """Compress envelope body and set content_encoding header."""
244
+ compressed, encoding = self.compress(envelope.body)
245
+ if encoding is not None:
246
+ # Create new envelope with compressed body and encoding
247
+ headers = dict(envelope.headers)
248
+ return MessageEnvelope(
249
+ routing_key=envelope.routing_key,
250
+ body=compressed,
251
+ exchange=envelope.exchange,
252
+ headers=headers,
253
+ message_id=envelope.message_id,
254
+ correlation_id=envelope.correlation_id,
255
+ reply_to=envelope.reply_to,
256
+ timestamp=envelope.timestamp,
257
+ content_type=envelope.content_type,
258
+ content_encoding=encoding,
259
+ expiration=envelope.expiration,
260
+ priority=envelope.priority,
261
+ mandatory=envelope.mandatory,
262
+ delivery_mode=envelope.delivery_mode,
263
+ type=envelope.type,
264
+ user_id=envelope.user_id,
265
+ app_id=envelope.app_id,
266
+ )
267
+ return envelope