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,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
|