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,203 @@
|
|
|
1
|
+
"""OTelTracingMiddleware — native OpenTelemetry tracing.
|
|
2
|
+
|
|
3
|
+
Speaks ``opentelemetry-api`` directly so publish→consume trace propagation
|
|
4
|
+
works for anyone, using W3C ``traceparent``/``tracestate`` headers over AMQP.
|
|
5
|
+
Install with the optional extra::
|
|
6
|
+
|
|
7
|
+
pip install rabbitkit[otel]
|
|
8
|
+
|
|
9
|
+
Lazy/no-op: if ``opentelemetry`` is not importable, every span is a
|
|
10
|
+
passthrough — and the middleware warns ONCE at construction (a caller who
|
|
11
|
+
adds it is opting into tracing; silently no-oping forever reads as
|
|
12
|
+
"nothing to trace yet" instead of "tracing was never active").
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from dataclasses import replace
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from rabbitkit.core.message import RabbitMessage
|
|
23
|
+
from rabbitkit.core.types import MessageEnvelope
|
|
24
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_otel() -> Any:
|
|
30
|
+
"""Lazy import of the ``opentelemetry`` API — returns module pair or ``None``."""
|
|
31
|
+
try:
|
|
32
|
+
from opentelemetry import propagate, trace
|
|
33
|
+
|
|
34
|
+
return trace, propagate
|
|
35
|
+
except ImportError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OTelTracingMiddleware(BaseMiddleware):
|
|
40
|
+
"""Wrap handler execution and publishes in standard OpenTelemetry spans.
|
|
41
|
+
|
|
42
|
+
- Consume: extracts W3C trace context from message headers and starts a
|
|
43
|
+
``CONSUMER``-kind span as its child. Handler exceptions are recorded on
|
|
44
|
+
the span (status ``ERROR``) and re-raised.
|
|
45
|
+
- Publish: starts a ``PRODUCER``-kind span and injects the current trace
|
|
46
|
+
context into a COPY of the envelope's headers (envelopes are frozen).
|
|
47
|
+
|
|
48
|
+
Span names follow the OTel messaging convention: ``{destination} receive``
|
|
49
|
+
/ ``{destination} send``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, service_name: str = "rabbitkit") -> None:
|
|
53
|
+
self._service_name = service_name
|
|
54
|
+
otel = _get_otel()
|
|
55
|
+
if otel is None:
|
|
56
|
+
self._trace: Any = None
|
|
57
|
+
self._propagate: Any = None
|
|
58
|
+
self._tracer: Any = None
|
|
59
|
+
logger.warning(
|
|
60
|
+
"OTelTracingMiddleware(service_name=%r) added but opentelemetry is not "
|
|
61
|
+
"installed -- every consume/publish span will be a silent no-op. "
|
|
62
|
+
"Install with `pip install rabbitkit[otel]`, or remove this middleware.",
|
|
63
|
+
service_name,
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
self._trace, self._propagate = otel
|
|
67
|
+
self._tracer = self._trace.get_tracer("rabbitkit", instrumenting_library_version="1")
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_available(self) -> bool:
|
|
71
|
+
"""True if the opentelemetry API is importable."""
|
|
72
|
+
return self._tracer is not None
|
|
73
|
+
|
|
74
|
+
# ── Internal helpers ──────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _str_carrier(headers: dict[str, Any]) -> dict[str, str]:
|
|
78
|
+
"""AMQP headers may hold non-str values; propagators want str→str."""
|
|
79
|
+
return {k: v for k, v in headers.items() if isinstance(v, str)}
|
|
80
|
+
|
|
81
|
+
def _consume_attributes(self, message: RabbitMessage) -> dict[str, str]:
|
|
82
|
+
attrs: dict[str, str] = {
|
|
83
|
+
"messaging.system": "rabbitmq",
|
|
84
|
+
"messaging.operation": "receive",
|
|
85
|
+
}
|
|
86
|
+
if message.routing_key:
|
|
87
|
+
attrs["messaging.rabbitmq.destination.routing_key"] = message.routing_key
|
|
88
|
+
queue = message.headers.get("x-rabbitkit-original-queue", "")
|
|
89
|
+
if queue:
|
|
90
|
+
attrs["messaging.destination.name"] = str(queue)
|
|
91
|
+
if message.message_id:
|
|
92
|
+
attrs["messaging.message.id"] = message.message_id
|
|
93
|
+
if message.correlation_id:
|
|
94
|
+
attrs["messaging.message.conversation_id"] = message.correlation_id
|
|
95
|
+
retry_count = message.headers.get("x-rabbitkit-retry-count")
|
|
96
|
+
if retry_count is not None:
|
|
97
|
+
attrs["messaging.rabbitmq.retry_count"] = str(retry_count)
|
|
98
|
+
return attrs
|
|
99
|
+
|
|
100
|
+
def _publish_attributes(self, envelope: MessageEnvelope) -> dict[str, str]:
|
|
101
|
+
attrs: dict[str, str] = {
|
|
102
|
+
"messaging.system": "rabbitmq",
|
|
103
|
+
"messaging.operation": "send",
|
|
104
|
+
}
|
|
105
|
+
if envelope.routing_key:
|
|
106
|
+
attrs["messaging.rabbitmq.destination.routing_key"] = envelope.routing_key
|
|
107
|
+
if envelope.exchange:
|
|
108
|
+
attrs["messaging.destination.name"] = envelope.exchange
|
|
109
|
+
if envelope.message_id:
|
|
110
|
+
attrs["messaging.message.id"] = envelope.message_id
|
|
111
|
+
if envelope.correlation_id:
|
|
112
|
+
attrs["messaging.message.conversation_id"] = envelope.correlation_id
|
|
113
|
+
return attrs
|
|
114
|
+
|
|
115
|
+
def _consume_span(self, message: RabbitMessage) -> Any:
|
|
116
|
+
ctx = self._propagate.extract(self._str_carrier(message.headers))
|
|
117
|
+
name = f"{message.headers.get('x-rabbitkit-original-queue', message.routing_key) or 'queue'} receive"
|
|
118
|
+
return self._tracer.start_as_current_span(
|
|
119
|
+
name,
|
|
120
|
+
context=ctx,
|
|
121
|
+
kind=self._trace.SpanKind.CONSUMER,
|
|
122
|
+
attributes=self._consume_attributes(message),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _publish_span(self, envelope: MessageEnvelope) -> Any:
|
|
126
|
+
name = f"{envelope.exchange or envelope.routing_key or 'exchange'} send"
|
|
127
|
+
return self._tracer.start_as_current_span(
|
|
128
|
+
name,
|
|
129
|
+
kind=self._trace.SpanKind.PRODUCER,
|
|
130
|
+
attributes=self._publish_attributes(envelope),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _record_failure(self, span: Any, exc: BaseException) -> None:
|
|
134
|
+
span.record_exception(exc)
|
|
135
|
+
span.set_status(self._trace.Status(self._trace.StatusCode.ERROR, str(exc)))
|
|
136
|
+
|
|
137
|
+
def _envelope_with_context(self, envelope: MessageEnvelope) -> MessageEnvelope:
|
|
138
|
+
"""Copy of *envelope* with the CURRENT trace context injected."""
|
|
139
|
+
carrier: dict[str, str] = {}
|
|
140
|
+
self._propagate.inject(carrier)
|
|
141
|
+
if not carrier:
|
|
142
|
+
return envelope
|
|
143
|
+
return replace(envelope, headers={**envelope.headers, **carrier})
|
|
144
|
+
|
|
145
|
+
# ── Consume-side hooks ────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def consume_scope(
|
|
148
|
+
self,
|
|
149
|
+
call_next: Callable[[RabbitMessage], Any],
|
|
150
|
+
message: RabbitMessage,
|
|
151
|
+
) -> Any:
|
|
152
|
+
if self._tracer is None:
|
|
153
|
+
return call_next(message)
|
|
154
|
+
with self._consume_span(message) as span:
|
|
155
|
+
try:
|
|
156
|
+
return call_next(message)
|
|
157
|
+
except BaseException as exc:
|
|
158
|
+
self._record_failure(span, exc)
|
|
159
|
+
raise
|
|
160
|
+
|
|
161
|
+
async def consume_scope_async(
|
|
162
|
+
self,
|
|
163
|
+
call_next: Callable[[RabbitMessage], Awaitable[Any]],
|
|
164
|
+
message: RabbitMessage,
|
|
165
|
+
) -> Any:
|
|
166
|
+
if self._tracer is None:
|
|
167
|
+
return await call_next(message)
|
|
168
|
+
with self._consume_span(message) as span:
|
|
169
|
+
try:
|
|
170
|
+
return await call_next(message)
|
|
171
|
+
except BaseException as exc:
|
|
172
|
+
self._record_failure(span, exc)
|
|
173
|
+
raise
|
|
174
|
+
|
|
175
|
+
# ── Publish-side hooks ────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def publish_scope(
|
|
178
|
+
self,
|
|
179
|
+
call_next: Callable[[MessageEnvelope], Any],
|
|
180
|
+
envelope: MessageEnvelope,
|
|
181
|
+
) -> Any:
|
|
182
|
+
if self._tracer is None:
|
|
183
|
+
return call_next(envelope)
|
|
184
|
+
with self._publish_span(envelope) as span:
|
|
185
|
+
try:
|
|
186
|
+
return call_next(self._envelope_with_context(envelope))
|
|
187
|
+
except BaseException as exc:
|
|
188
|
+
self._record_failure(span, exc)
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
async def publish_scope_async(
|
|
192
|
+
self,
|
|
193
|
+
call_next: Callable[[MessageEnvelope], Awaitable[Any]],
|
|
194
|
+
envelope: MessageEnvelope,
|
|
195
|
+
) -> Any:
|
|
196
|
+
if self._tracer is None:
|
|
197
|
+
return await call_next(envelope)
|
|
198
|
+
with self._publish_span(envelope) as span:
|
|
199
|
+
try:
|
|
200
|
+
return await call_next(self._envelope_with_context(envelope))
|
|
201
|
+
except BaseException as exc:
|
|
202
|
+
self._record_failure(span, exc)
|
|
203
|
+
raise
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Rate-limiting middleware for message consumption.
|
|
2
|
+
|
|
3
|
+
Limits the rate of message processing using a **token bucket** algorithm.
|
|
4
|
+
Thread-safe for sync consumers; uses ``asyncio.sleep`` for async consumers.
|
|
5
|
+
|
|
6
|
+
Three behaviours when the rate is exceeded (``on_limited``):
|
|
7
|
+
|
|
8
|
+
* ``"wait"`` — sleep until a token is available (default, back-pressures the consumer)
|
|
9
|
+
* ``"nack"`` — nack with ``requeue=True`` so another consumer / retry can handle it
|
|
10
|
+
* ``"drop"`` — nack with ``requeue=False`` (message discarded / sent to DLQ)
|
|
11
|
+
|
|
12
|
+
Quick start
|
|
13
|
+
-----------
|
|
14
|
+
from rabbitkit.middleware.rate_limit import RateLimitMiddleware, RateLimitConfig
|
|
15
|
+
|
|
16
|
+
rate_mw = RateLimitMiddleware(
|
|
17
|
+
RateLimitConfig(max_rate=100.0, burst=10, on_limited="wait")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@broker.subscriber(queue="events", middlewares=[rate_mw])
|
|
21
|
+
async def handle_event(body: bytes) -> None:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
Per-consumer vs broker-wide
|
|
25
|
+
----------------------------
|
|
26
|
+
Attach as a per-route middleware to scope the limit to one queue:
|
|
27
|
+
|
|
28
|
+
@broker.subscriber(queue="high-volume", middlewares=[rate_mw])
|
|
29
|
+
def handle(body: bytes) -> None: ...
|
|
30
|
+
|
|
31
|
+
Or attach broker-wide by passing it in the broker constructor's middleware
|
|
32
|
+
list (if supported by your broker version).
|
|
33
|
+
|
|
34
|
+
Combining with FlowController
|
|
35
|
+
------------------------------
|
|
36
|
+
``RateLimitMiddleware`` limits the *consumer* side (processing rate).
|
|
37
|
+
``FlowController`` / ``BackpressureConfig`` limits the *publisher* side.
|
|
38
|
+
Use both together for full end-to-end flow control:
|
|
39
|
+
|
|
40
|
+
from rabbitkit import FlowController, BackpressureConfig
|
|
41
|
+
from rabbitkit.middleware.rate_limit import RateLimitMiddleware, RateLimitConfig
|
|
42
|
+
|
|
43
|
+
# Publisher-side: max 5 000 msgs/s
|
|
44
|
+
fc = FlowController(BackpressureConfig(rate_limit=5000))
|
|
45
|
+
|
|
46
|
+
# Consumer-side: process max 200 msgs/s, nack the rest
|
|
47
|
+
rate_mw = RateLimitMiddleware(RateLimitConfig(max_rate=200, on_limited="nack"))
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import asyncio
|
|
53
|
+
import logging
|
|
54
|
+
import threading
|
|
55
|
+
import time
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from typing import Any
|
|
58
|
+
|
|
59
|
+
from rabbitkit.core.message import RabbitMessage
|
|
60
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class RateLimitConfig:
|
|
67
|
+
"""Configuration for rate limiting.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
max_rate: Maximum messages per second.
|
|
71
|
+
burst: Maximum burst size above steady rate.
|
|
72
|
+
on_limited: Behavior when rate exceeded: "wait", "nack", or "drop".
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
max_rate: float
|
|
76
|
+
burst: int = 1
|
|
77
|
+
on_limited: str = "wait" # "wait" | "nack" | "drop"
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
if self.max_rate <= 0:
|
|
81
|
+
raise ValueError("max_rate must be positive")
|
|
82
|
+
if self.burst < 1:
|
|
83
|
+
raise ValueError("burst must be >= 1")
|
|
84
|
+
if self.on_limited not in ("wait", "nack", "drop"):
|
|
85
|
+
raise ValueError(f"on_limited must be 'wait', 'nack', or 'drop', got '{self.on_limited}'")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _TokenBucket:
|
|
89
|
+
"""Thread-safe token bucket for rate limiting."""
|
|
90
|
+
|
|
91
|
+
__slots__ = ("_capacity", "_last_refill", "_lock", "_rate", "_tokens")
|
|
92
|
+
|
|
93
|
+
def __init__(self, rate: float, capacity: int) -> None:
|
|
94
|
+
self._rate = rate
|
|
95
|
+
self._capacity = capacity
|
|
96
|
+
self._tokens = float(capacity)
|
|
97
|
+
self._last_refill = time.monotonic()
|
|
98
|
+
self._lock = threading.Lock()
|
|
99
|
+
|
|
100
|
+
def _refill(self) -> None:
|
|
101
|
+
now = time.monotonic()
|
|
102
|
+
elapsed = now - self._last_refill
|
|
103
|
+
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
|
|
104
|
+
self._last_refill = now
|
|
105
|
+
|
|
106
|
+
def try_acquire(self) -> bool:
|
|
107
|
+
"""Try to acquire a token without blocking. Returns True if acquired."""
|
|
108
|
+
with self._lock:
|
|
109
|
+
self._refill()
|
|
110
|
+
if self._tokens >= 1.0:
|
|
111
|
+
self._tokens -= 1.0
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def wait_time(self) -> float:
|
|
116
|
+
"""Return seconds to wait until a token is available."""
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._refill()
|
|
119
|
+
if self._tokens >= 1.0:
|
|
120
|
+
return 0.0
|
|
121
|
+
deficit = 1.0 - self._tokens
|
|
122
|
+
return deficit / self._rate
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RateLimitMiddleware(BaseMiddleware):
|
|
126
|
+
"""Limits message processing rate using a token bucket.
|
|
127
|
+
|
|
128
|
+
Behavior when rate is exceeded (configurable via on_limited):
|
|
129
|
+
- "wait": Sleep until a token is available (default)
|
|
130
|
+
- "nack": Reject message with requeue=True (another consumer can try)
|
|
131
|
+
- "drop": Reject message with requeue=False (message is lost/goes to DLQ)
|
|
132
|
+
|
|
133
|
+
L5 — per-process scoping: the token bucket lives in this process's
|
|
134
|
+
memory. With ``worker_count`` > 1 consumer processes/pods sharing a
|
|
135
|
+
queue, each gets its own independent bucket — the effective CLUSTER
|
|
136
|
+
rate is ``workers x max_rate``, not ``max_rate``. Size ``max_rate``
|
|
137
|
+
accordingly, or put a cluster-wide limiter in front (e.g. a
|
|
138
|
+
Redis-backed token bucket) if you need a hard global cap. Under
|
|
139
|
+
sustained overload, "wait" blocks for up to ``_wait_deadline`` (30s by
|
|
140
|
+
default) before falling back to a drop (nack, no requeue) — every
|
|
141
|
+
nack/drop/wait-timeout is logged at WARNING and, if you pass
|
|
142
|
+
``metrics_collector``, increments ``rate_limit_dropped_total`` labeled
|
|
143
|
+
by ``reason`` so sustained overload is observable instead of silent.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
config: RateLimitConfig,
|
|
149
|
+
*,
|
|
150
|
+
metrics_collector: Any | None = None,
|
|
151
|
+
metrics_config: Any | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
self._config = config
|
|
154
|
+
self._bucket = _TokenBucket(config.max_rate, config.burst)
|
|
155
|
+
# Maximum time (s) a "wait" policy will block for a token before falling
|
|
156
|
+
# back to drop semantics. Override per-instance if needed.
|
|
157
|
+
self._wait_deadline: float = 30.0
|
|
158
|
+
self._metrics_collector = metrics_collector
|
|
159
|
+
self._metrics_config = metrics_config
|
|
160
|
+
|
|
161
|
+
def _record_drop(self, reason: str) -> None:
|
|
162
|
+
"""L5: log + optional metric every time a message is settled without
|
|
163
|
+
the handler running, so sustained rate-limit pressure is observable."""
|
|
164
|
+
logger.warning("RateLimitMiddleware dropped a message (reason=%s)", reason)
|
|
165
|
+
if self._metrics_collector is not None and self._metrics_config is not None:
|
|
166
|
+
self._metrics_collector.inc_counter(
|
|
167
|
+
self._metrics_config.rate_limit_dropped_total, {"reason": reason}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def consume_scope(
|
|
171
|
+
self,
|
|
172
|
+
call_next: Any,
|
|
173
|
+
message: RabbitMessage,
|
|
174
|
+
) -> Any:
|
|
175
|
+
"""Rate-limit sync message processing.
|
|
176
|
+
|
|
177
|
+
For ``on_limited="wait"`` the loop polls the token bucket until a token
|
|
178
|
+
is acquired **or** ``wait_deadline`` (seconds, monotonic) expires. If the
|
|
179
|
+
deadline elapses with no token, the message falls back to the configured
|
|
180
|
+
drop/nack semantics so the handler is **never** invoked without a token.
|
|
181
|
+
"""
|
|
182
|
+
if self._bucket.try_acquire():
|
|
183
|
+
return call_next(message)
|
|
184
|
+
|
|
185
|
+
if self._config.on_limited == "nack":
|
|
186
|
+
self._record_drop("nack")
|
|
187
|
+
if not message.is_settled:
|
|
188
|
+
message.nack(requeue=True)
|
|
189
|
+
return None
|
|
190
|
+
if self._config.on_limited == "drop":
|
|
191
|
+
self._record_drop("drop")
|
|
192
|
+
if not message.is_settled:
|
|
193
|
+
message.nack(requeue=False)
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
# "wait" — bounded loop; only proceed once a token is actually acquired.
|
|
197
|
+
deadline = time.monotonic() + self._wait_deadline
|
|
198
|
+
while not self._bucket.try_acquire():
|
|
199
|
+
remaining = deadline - time.monotonic()
|
|
200
|
+
if remaining <= 0:
|
|
201
|
+
# No token within the deadline — fall back to drop semantics so
|
|
202
|
+
# the handler is NOT called without a token.
|
|
203
|
+
self._record_drop("wait_deadline_exceeded")
|
|
204
|
+
if not message.is_settled:
|
|
205
|
+
message.nack(requeue=False)
|
|
206
|
+
return None
|
|
207
|
+
time.sleep(min(self._bucket.wait_time(), remaining))
|
|
208
|
+
|
|
209
|
+
return call_next(message)
|
|
210
|
+
|
|
211
|
+
async def consume_scope_async(
|
|
212
|
+
self,
|
|
213
|
+
call_next: Any,
|
|
214
|
+
message: RabbitMessage,
|
|
215
|
+
) -> Any:
|
|
216
|
+
"""Rate-limit async message processing.
|
|
217
|
+
|
|
218
|
+
Mirrors the sync logic: the "wait" loop is bounded by
|
|
219
|
+
``self._wait_deadline`` and falls back to drop semantics if no token is
|
|
220
|
+
acquired in time, so the handler is never called without a token.
|
|
221
|
+
"""
|
|
222
|
+
if self._bucket.try_acquire():
|
|
223
|
+
return await call_next(message)
|
|
224
|
+
|
|
225
|
+
if self._config.on_limited == "nack":
|
|
226
|
+
self._record_drop("nack")
|
|
227
|
+
if not message.is_settled:
|
|
228
|
+
await message.nack_async(requeue=True)
|
|
229
|
+
return None
|
|
230
|
+
if self._config.on_limited == "drop":
|
|
231
|
+
self._record_drop("drop")
|
|
232
|
+
if not message.is_settled:
|
|
233
|
+
await message.nack_async(requeue=False)
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
# "wait" — bounded loop; only proceed once a token is actually acquired.
|
|
237
|
+
deadline = time.monotonic() + self._wait_deadline
|
|
238
|
+
while not self._bucket.try_acquire():
|
|
239
|
+
remaining = deadline - time.monotonic()
|
|
240
|
+
if remaining <= 0:
|
|
241
|
+
self._record_drop("wait_deadline_exceeded")
|
|
242
|
+
if not message.is_settled:
|
|
243
|
+
await message.nack_async(requeue=False)
|
|
244
|
+
return None
|
|
245
|
+
await asyncio.sleep(min(self._bucket.wait_time(), remaining))
|
|
246
|
+
|
|
247
|
+
return await call_next(message)
|