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