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,349 @@
|
|
|
1
|
+
"""Transport ABCs and capability sub-protocols.
|
|
2
|
+
|
|
3
|
+
Defines the minimal interfaces that sync and async transports must implement,
|
|
4
|
+
plus opt-in capability protocols for publisher confirms, backpressure, RPC,
|
|
5
|
+
circuit breakers, and metrics.
|
|
6
|
+
|
|
7
|
+
ZERO pika or aio-pika imports — truly transport-agnostic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from rabbitkit.core.message import RabbitMessage
|
|
16
|
+
from rabbitkit.core.topology import RabbitExchange, RabbitQueue
|
|
17
|
+
from rabbitkit.core.types import MessageEnvelope, PublishOutcome
|
|
18
|
+
|
|
19
|
+
# ── Core transport protocols ──────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class Transport(Protocol):
|
|
24
|
+
"""Sync transport — implemented by sync/transport.py.
|
|
25
|
+
|
|
26
|
+
Minimal interface for sync message broker I/O.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def connect(self) -> None:
|
|
30
|
+
"""Establish connection to broker."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def disconnect(self) -> None:
|
|
34
|
+
"""Close connection to broker."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def is_connected(self) -> bool:
|
|
38
|
+
"""Check if transport is connected."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def publish(self, envelope: MessageEnvelope) -> PublishOutcome:
|
|
42
|
+
"""Publish a message. Returns outcome with confirm status."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def consume(
|
|
46
|
+
self,
|
|
47
|
+
queue: str,
|
|
48
|
+
callback: Callable[[RabbitMessage], None],
|
|
49
|
+
prefetch: int = 10,
|
|
50
|
+
*,
|
|
51
|
+
no_ack: bool = False,
|
|
52
|
+
declare: bool = True,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""Start consuming from a queue. Returns consumer_tag.
|
|
55
|
+
|
|
56
|
+
``no_ack=True`` starts a no-ack consumer (the broker auto-acks on
|
|
57
|
+
delivery; the built ``RabbitMessage`` gets no settlement functions).
|
|
58
|
+
``declare=False`` skips declaring/checking the queue first — required
|
|
59
|
+
for AMQP pseudo-queues such as ``amq.rabbitmq.reply-to``, which the
|
|
60
|
+
broker rejects any Queue.Declare for (active or passive).
|
|
61
|
+
"""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def declare_exchange(self, exchange: RabbitExchange) -> None:
|
|
65
|
+
"""Declare an exchange on the broker."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def declare_queue(self, queue: RabbitQueue) -> None:
|
|
69
|
+
"""Declare a queue on the broker."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def bind_queue(
|
|
73
|
+
self,
|
|
74
|
+
queue: str,
|
|
75
|
+
exchange: str,
|
|
76
|
+
routing_key: str,
|
|
77
|
+
arguments: dict[str, Any] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Bind a queue to an exchange with a routing key.
|
|
80
|
+
|
|
81
|
+
``arguments`` carries header-match criteria for HEADERS exchanges.
|
|
82
|
+
"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
def bind_exchange(
|
|
86
|
+
self,
|
|
87
|
+
destination: str,
|
|
88
|
+
source: str,
|
|
89
|
+
routing_key: str = "",
|
|
90
|
+
arguments: dict[str, Any] | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Bind an exchange to another exchange (exchange-to-exchange binding)."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
def cancel_consumer(self, consumer_tag: str) -> None:
|
|
96
|
+
"""Cancel a consumer by its tag."""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@runtime_checkable
|
|
101
|
+
class AsyncTransport(Protocol):
|
|
102
|
+
"""Async transport — implemented by async_/transport.py.
|
|
103
|
+
|
|
104
|
+
Minimal interface for async message broker I/O.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
async def connect(self) -> None:
|
|
108
|
+
"""Establish connection to broker."""
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
async def disconnect(self) -> None:
|
|
112
|
+
"""Close connection to broker."""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
def is_connected(self) -> bool:
|
|
116
|
+
"""Check if transport is connected."""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
async def publish(self, envelope: MessageEnvelope) -> PublishOutcome:
|
|
120
|
+
"""Publish a message. Returns outcome with confirm status."""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
async def consume(
|
|
124
|
+
self,
|
|
125
|
+
queue: str,
|
|
126
|
+
callback: Callable[[RabbitMessage], Awaitable[None]],
|
|
127
|
+
prefetch: int = 10,
|
|
128
|
+
*,
|
|
129
|
+
no_ack: bool = False,
|
|
130
|
+
declare: bool = True,
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Start consuming from a queue. Returns consumer_tag.
|
|
133
|
+
|
|
134
|
+
``no_ack=True`` starts a no-ack consumer (the broker auto-acks on
|
|
135
|
+
delivery; the built ``RabbitMessage`` gets no settlement functions).
|
|
136
|
+
``declare=False`` skips declaring/checking the queue first — required
|
|
137
|
+
for AMQP pseudo-queues such as ``amq.rabbitmq.reply-to``, which the
|
|
138
|
+
broker rejects any Queue.Declare for (active or passive).
|
|
139
|
+
"""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
async def declare_exchange(self, exchange: RabbitExchange) -> None:
|
|
143
|
+
"""Declare an exchange on the broker."""
|
|
144
|
+
...
|
|
145
|
+
|
|
146
|
+
async def declare_queue(self, queue: RabbitQueue) -> None:
|
|
147
|
+
"""Declare a queue on the broker."""
|
|
148
|
+
...
|
|
149
|
+
|
|
150
|
+
async def bind_queue(
|
|
151
|
+
self,
|
|
152
|
+
queue: str,
|
|
153
|
+
exchange: str,
|
|
154
|
+
routing_key: str,
|
|
155
|
+
arguments: dict[str, Any] | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Bind a queue to an exchange with a routing key.
|
|
158
|
+
|
|
159
|
+
``arguments`` carries header-match criteria for HEADERS exchanges.
|
|
160
|
+
"""
|
|
161
|
+
...
|
|
162
|
+
|
|
163
|
+
async def bind_exchange(
|
|
164
|
+
self,
|
|
165
|
+
destination: str,
|
|
166
|
+
source: str,
|
|
167
|
+
routing_key: str = "",
|
|
168
|
+
arguments: dict[str, Any] | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Bind an exchange to another exchange (exchange-to-exchange binding)."""
|
|
171
|
+
...
|
|
172
|
+
|
|
173
|
+
async def cancel_consumer(self, consumer_tag: str) -> None:
|
|
174
|
+
"""Cancel a consumer by its tag."""
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── DLQ / inspection sub-protocols ────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@runtime_checkable
|
|
182
|
+
class SupportsBasicGet(Protocol):
|
|
183
|
+
"""Transport supports basic.get (single-message fetch)."""
|
|
184
|
+
|
|
185
|
+
def basic_get(self, queue: str) -> RabbitMessage | None:
|
|
186
|
+
"""Fetch a single message from a queue. Returns None if empty."""
|
|
187
|
+
...
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@runtime_checkable
|
|
191
|
+
class AsyncSupportsBasicGet(Protocol):
|
|
192
|
+
"""Async transport supports basic.get."""
|
|
193
|
+
|
|
194
|
+
async def basic_get(self, queue: str) -> RabbitMessage | None:
|
|
195
|
+
"""Fetch a single message from a queue. Returns None if empty."""
|
|
196
|
+
...
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@runtime_checkable
|
|
200
|
+
class SupportsPurge(Protocol):
|
|
201
|
+
"""Transport supports queue purge."""
|
|
202
|
+
|
|
203
|
+
def purge_queue(self, queue: str) -> int:
|
|
204
|
+
"""Purge all messages from a queue. Returns purged count."""
|
|
205
|
+
...
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@runtime_checkable
|
|
209
|
+
class AsyncSupportsPurge(Protocol):
|
|
210
|
+
"""Async transport supports queue purge."""
|
|
211
|
+
|
|
212
|
+
async def purge_queue(self, queue: str) -> int:
|
|
213
|
+
"""Purge all messages from a queue. Returns purged count."""
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── Capability sub-protocols (opt-in) ─────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@runtime_checkable
|
|
221
|
+
class SupportsPublisherConfirms(Protocol):
|
|
222
|
+
"""Transport supports confirm_delivery mode."""
|
|
223
|
+
|
|
224
|
+
def enable_confirms(self) -> None:
|
|
225
|
+
"""Enable publisher confirms on the channel."""
|
|
226
|
+
...
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@runtime_checkable
|
|
230
|
+
class SupportsBackpressure(Protocol):
|
|
231
|
+
"""Transport supports connection.blocked/unblocked callbacks."""
|
|
232
|
+
|
|
233
|
+
def on_blocked(self, callback: Callable[[], None]) -> None:
|
|
234
|
+
"""Register callback for connection.blocked."""
|
|
235
|
+
...
|
|
236
|
+
|
|
237
|
+
def on_unblocked(self, callback: Callable[[], None]) -> None:
|
|
238
|
+
"""Register callback for connection.unblocked."""
|
|
239
|
+
...
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@runtime_checkable
|
|
243
|
+
class SupportsRPC(Protocol):
|
|
244
|
+
"""Transport supports exclusive reply queues for RPC."""
|
|
245
|
+
|
|
246
|
+
def create_reply_queue(self) -> str:
|
|
247
|
+
"""Create an exclusive reply queue. Returns queue name."""
|
|
248
|
+
...
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ── Generic extension protocols ──────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@runtime_checkable
|
|
255
|
+
class CircuitBreakerProtocol(Protocol):
|
|
256
|
+
"""Generic circuit breaker protocol.
|
|
257
|
+
|
|
258
|
+
pybreaker (among others) satisfies this interface.
|
|
259
|
+
Used optionally by transports — core works without any CB.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
263
|
+
"""Execute func through circuit breaker."""
|
|
264
|
+
...
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@runtime_checkable
|
|
268
|
+
class AsyncCircuitBreakerProtocol(Protocol):
|
|
269
|
+
"""Async circuit breaker protocol.
|
|
270
|
+
|
|
271
|
+
For async transports that need async circuit breaker support.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
async def call_async(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> Any:
|
|
275
|
+
"""Execute async func through circuit breaker."""
|
|
276
|
+
...
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@runtime_checkable
|
|
280
|
+
class MetricsCollector(Protocol):
|
|
281
|
+
"""Optional metrics collector protocol.
|
|
282
|
+
|
|
283
|
+
Used for observability integration; rabbitkit ships PrometheusCollector.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def increment(self, name: str, tags: dict[str, str] | None = None) -> None:
|
|
287
|
+
"""Increment a counter metric."""
|
|
288
|
+
...
|
|
289
|
+
|
|
290
|
+
def histogram(self, name: str, value: float, tags: dict[str, str] | None = None) -> None:
|
|
291
|
+
"""Record a histogram metric value."""
|
|
292
|
+
...
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@runtime_checkable
|
|
296
|
+
class AsyncMetricsCollector(Protocol):
|
|
297
|
+
"""Async metrics collector for async transports."""
|
|
298
|
+
|
|
299
|
+
async def increment(self, name: str, tags: dict[str, str] | None = None) -> None:
|
|
300
|
+
"""Increment a counter metric."""
|
|
301
|
+
...
|
|
302
|
+
|
|
303
|
+
async def histogram(self, name: str, value: float, tags: dict[str, str] | None = None) -> None:
|
|
304
|
+
"""Record a histogram metric value."""
|
|
305
|
+
...
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ── Health sub-protocol ─────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@runtime_checkable
|
|
312
|
+
class HealthProvider(Protocol):
|
|
313
|
+
"""Broker health surface — typed alternative to private-attribute probing.
|
|
314
|
+
|
|
315
|
+
Brokers that expose these read-only properties satisfy the protocol and
|
|
316
|
+
:mod:`rabbitkit.health` will use them directly. Brokers that still use
|
|
317
|
+
private attributes (``_started``, ``_transport``, ...) are supported via
|
|
318
|
+
the deprecation fallback in :func:`rabbitkit.health._get`.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def started(self) -> bool:
|
|
323
|
+
"""Whether the broker has been started."""
|
|
324
|
+
...
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def connected(self) -> bool:
|
|
328
|
+
"""Whether the underlying transport is connected."""
|
|
329
|
+
...
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def consumer_count(self) -> int:
|
|
333
|
+
"""Number of routes with an active (live) consumer."""
|
|
334
|
+
...
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def route_count(self) -> int:
|
|
338
|
+
"""Total number of registered routes."""
|
|
339
|
+
...
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def worker_pool_pending(self) -> int:
|
|
343
|
+
"""Current worker-pool backlog (pending tasks)."""
|
|
344
|
+
...
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def last_heartbeat(self) -> float | None:
|
|
348
|
+
"""Last liveness heartbeat (monotonic seconds), or None."""
|
|
349
|
+
...
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Subscriber/Publisher registry — stores all @subscriber/@publisher registrations.
|
|
2
|
+
|
|
3
|
+
Semantic rules:
|
|
4
|
+
- One handler per queue (duplicate → DuplicateRouteError at registration time)
|
|
5
|
+
- str queue → auto-creates RabbitQueue(name=str, durable=True)
|
|
6
|
+
- str exchange → auto-creates RabbitExchange(name=str, type=DIRECT)
|
|
7
|
+
- @publisher without @subscriber → raises ConfigurationError
|
|
8
|
+
- @publisher BEFORE @subscriber on same handler → sets result_publisher
|
|
9
|
+
|
|
10
|
+
Registration-time retry conflict checks (fail fast):
|
|
11
|
+
When a route is registered, the registry resolves the effective retry policy
|
|
12
|
+
and validates retry + ack policy + DLX config compatibility.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from rabbitkit.core.config import RetryConfig, RetryDisabled
|
|
21
|
+
from rabbitkit.core.message import RabbitMessage
|
|
22
|
+
from rabbitkit.core.route import ResultPublisher, RouteDefinition
|
|
23
|
+
from rabbitkit.core.topology import RabbitExchange, RabbitQueue
|
|
24
|
+
from rabbitkit.core.types import AckPolicy
|
|
25
|
+
from rabbitkit.serialization.base import Serializer
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from rabbitkit.core.router import RabbitRouter
|
|
29
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DuplicateRouteError(Exception):
|
|
33
|
+
"""Raised when two routes target the same queue."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubscriberRegistry:
|
|
37
|
+
"""Stores all @subscriber/@publisher registrations.
|
|
38
|
+
|
|
39
|
+
Used by RabbitApp and RabbitRouter to collect route definitions
|
|
40
|
+
which are later wired by the broker.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, broker_retry: RetryConfig | None = None) -> None:
|
|
44
|
+
self._routes: list[RouteDefinition] = []
|
|
45
|
+
self._queue_names: set[str] = set()
|
|
46
|
+
self._pending_publishers: dict[int, ResultPublisher] = {} # handler id → ResultPublisher
|
|
47
|
+
self._broker_retry = broker_retry
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def routes(self) -> list[RouteDefinition]:
|
|
51
|
+
"""Return all registered routes."""
|
|
52
|
+
return list(self._routes)
|
|
53
|
+
|
|
54
|
+
def subscriber(
|
|
55
|
+
self,
|
|
56
|
+
queue: RabbitQueue | str,
|
|
57
|
+
exchange: RabbitExchange | str | None = None,
|
|
58
|
+
routing_key: str = "",
|
|
59
|
+
ack_policy: AckPolicy = AckPolicy.AUTO,
|
|
60
|
+
middlewares: list[BaseMiddleware] | None = None,
|
|
61
|
+
serializer: Serializer[Any] | None = None,
|
|
62
|
+
retry: RetryConfig | RetryDisabled | None = None,
|
|
63
|
+
tags: frozenset[str] | set[str] | None = None,
|
|
64
|
+
description: str = "",
|
|
65
|
+
name: str | None = None,
|
|
66
|
+
prefetch_count: int | None = None,
|
|
67
|
+
filter_fn: Callable[[RabbitMessage], bool] | None = None,
|
|
68
|
+
reject_without_dlx: str | None = None,
|
|
69
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
70
|
+
"""Decorator to register a message handler for a queue.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
queue: Queue to consume from (str auto-creates RabbitQueue).
|
|
74
|
+
exchange: Exchange to bind to (str auto-creates RabbitExchange).
|
|
75
|
+
routing_key: Routing key for binding.
|
|
76
|
+
ack_policy: Acknowledgment policy for this route.
|
|
77
|
+
middlewares: Route-specific middleware list.
|
|
78
|
+
serializer: Route-specific serializer override.
|
|
79
|
+
retry: Per-route retry config (None=inherit, RETRY_DISABLED=opt-out).
|
|
80
|
+
tags: Route tags for filtering/grouping.
|
|
81
|
+
description: Human-readable route description.
|
|
82
|
+
name: Explicit route name (auto-generated if None).
|
|
83
|
+
prefetch_count: Per-route prefetch override (None=use global).
|
|
84
|
+
reject_without_dlx: Per-route override of
|
|
85
|
+
``SafetyConfig.reject_without_dlx`` — 'auto_provision',
|
|
86
|
+
'error', or 'discard' (None=inherit broker default).
|
|
87
|
+
"""
|
|
88
|
+
# Normalize queue
|
|
89
|
+
if isinstance(queue, str):
|
|
90
|
+
queue = RabbitQueue(name=queue)
|
|
91
|
+
|
|
92
|
+
# Apply routing_key to the queue if not already set. The queue is frozen,
|
|
93
|
+
# so build a copy rather than mutating the caller's object (which may be
|
|
94
|
+
# shared across brokers/routers — see topology freeze + include-router).
|
|
95
|
+
if routing_key and not queue.routing_key:
|
|
96
|
+
from dataclasses import replace as _replace
|
|
97
|
+
|
|
98
|
+
queue = _replace(queue, routing_key=routing_key)
|
|
99
|
+
|
|
100
|
+
# Normalize exchange
|
|
101
|
+
if isinstance(exchange, str):
|
|
102
|
+
exchange = RabbitExchange(name=exchange)
|
|
103
|
+
|
|
104
|
+
# Normalize tags
|
|
105
|
+
if tags is not None and not isinstance(tags, frozenset):
|
|
106
|
+
tags = frozenset(tags)
|
|
107
|
+
elif tags is None:
|
|
108
|
+
tags = frozenset()
|
|
109
|
+
|
|
110
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
111
|
+
# Validate handler signature at registration time (fail fast).
|
|
112
|
+
# Covers *args/**kwargs and multiple body-like parameters (Contract 4).
|
|
113
|
+
from rabbitkit.di.resolver import DIResolver
|
|
114
|
+
|
|
115
|
+
DIResolver().validate_handler(func)
|
|
116
|
+
|
|
117
|
+
# Check duplicate queue
|
|
118
|
+
if queue.name in self._queue_names:
|
|
119
|
+
raise DuplicateRouteError(
|
|
120
|
+
f"Queue '{queue.name}' already has a registered handler. "
|
|
121
|
+
"rabbitkit enforces one handler per queue. "
|
|
122
|
+
"Use multiple routing keys on the same queue for fan-in."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Auto-generate name
|
|
126
|
+
route_name = name or f"{queue.name}:{func.__qualname__}"
|
|
127
|
+
|
|
128
|
+
# Check for pending @publisher
|
|
129
|
+
result_publisher = self._pending_publishers.pop(id(func), None)
|
|
130
|
+
|
|
131
|
+
# Create route
|
|
132
|
+
route = RouteDefinition(
|
|
133
|
+
name=route_name,
|
|
134
|
+
queue=queue,
|
|
135
|
+
exchange=exchange,
|
|
136
|
+
handler=func,
|
|
137
|
+
ack_policy=ack_policy,
|
|
138
|
+
route_middlewares=middlewares or [],
|
|
139
|
+
result_publisher=result_publisher,
|
|
140
|
+
serializer_override=serializer,
|
|
141
|
+
retry_override=retry,
|
|
142
|
+
prefetch_count=prefetch_count,
|
|
143
|
+
tags=tags,
|
|
144
|
+
description=description,
|
|
145
|
+
filter_fn=filter_fn,
|
|
146
|
+
reject_without_dlx=reject_without_dlx,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Validate at registration time (fail fast)
|
|
150
|
+
route.validate(self._broker_retry)
|
|
151
|
+
|
|
152
|
+
self._routes.append(route)
|
|
153
|
+
self._queue_names.add(queue.name)
|
|
154
|
+
|
|
155
|
+
# M-C6: detect dead-letter-exchange cycles across the growing route graph.
|
|
156
|
+
self.validate_dlx_graph()
|
|
157
|
+
|
|
158
|
+
return func
|
|
159
|
+
|
|
160
|
+
return decorator
|
|
161
|
+
|
|
162
|
+
def publisher(
|
|
163
|
+
self,
|
|
164
|
+
exchange: RabbitExchange | str | None = None,
|
|
165
|
+
routing_key: str = "",
|
|
166
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
167
|
+
"""Decorator to configure result publishing for a handler.
|
|
168
|
+
|
|
169
|
+
Must be applied BEFORE @subscriber on the same handler.
|
|
170
|
+
If applied without @subscriber, the publisher info is stored
|
|
171
|
+
and applied when @subscriber is later applied.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
exchange: Target exchange for result publishing.
|
|
175
|
+
routing_key: Routing key for result publishing.
|
|
176
|
+
"""
|
|
177
|
+
# Normalize exchange
|
|
178
|
+
if isinstance(exchange, str):
|
|
179
|
+
exchange = RabbitExchange(name=exchange)
|
|
180
|
+
|
|
181
|
+
result_pub = ResultPublisher(exchange=exchange, routing_key=routing_key)
|
|
182
|
+
|
|
183
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
184
|
+
# Store pending publisher for this handler
|
|
185
|
+
self._pending_publishers[id(func)] = result_pub
|
|
186
|
+
return func
|
|
187
|
+
|
|
188
|
+
return decorator
|
|
189
|
+
|
|
190
|
+
def include_router(self, router: RabbitRouter, prefix: str = "") -> None:
|
|
191
|
+
"""Include routes from a RabbitRouter.
|
|
192
|
+
|
|
193
|
+
Applies the router's prefix to routing keys and merges
|
|
194
|
+
router-level defaults (exchange, middlewares, serializer, tags).
|
|
195
|
+
"""
|
|
196
|
+
# Duck-typed check retained despite the static RabbitRouter annotation:
|
|
197
|
+
# this is a public entry point Python won't enforce the type on at
|
|
198
|
+
# runtime, and a clear TypeError beats an opaque AttributeError below.
|
|
199
|
+
if not hasattr(router, "_registry"):
|
|
200
|
+
raise TypeError(f"Expected a RabbitRouter, got {type(router).__name__}")
|
|
201
|
+
|
|
202
|
+
for route in router._registry.routes:
|
|
203
|
+
# Apply prefix to routing key WITHOUT mutating the included router's
|
|
204
|
+
# RabbitQueue/RouteDefinition (frozen + shared-object safety). Build
|
|
205
|
+
# fresh copies so re-including the same router under a different
|
|
206
|
+
# prefix doesn't double-prefix or cross-contaminate.
|
|
207
|
+
from dataclasses import replace as _replace
|
|
208
|
+
|
|
209
|
+
if prefix:
|
|
210
|
+
effective_rk = f"{prefix}.{route.queue.routing_key}" if route.queue.routing_key else prefix
|
|
211
|
+
new_queue = _replace(route.queue, routing_key=effective_rk)
|
|
212
|
+
route = _replace(route, queue=new_queue)
|
|
213
|
+
|
|
214
|
+
# Check duplicate
|
|
215
|
+
if route.queue.name in self._queue_names:
|
|
216
|
+
raise DuplicateRouteError(
|
|
217
|
+
f"Queue '{route.queue.name}' already has a registered handler. Duplicate from included router."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Validate with broker retry context
|
|
221
|
+
route.validate(self._broker_retry)
|
|
222
|
+
|
|
223
|
+
self._routes.append(route)
|
|
224
|
+
self._queue_names.add(route.queue.name)
|
|
225
|
+
|
|
226
|
+
# M-C6: detect DLX cycles after including router routes.
|
|
227
|
+
self.validate_dlx_graph()
|
|
228
|
+
|
|
229
|
+
def set_broker_retry(self, retry: RetryConfig | None) -> None:
|
|
230
|
+
"""Update broker retry default. Re-validates all existing routes."""
|
|
231
|
+
self._broker_retry = retry
|
|
232
|
+
for route in self._routes:
|
|
233
|
+
route.validate(self._broker_retry)
|
|
234
|
+
|
|
235
|
+
def validate_dlx_graph(self) -> None:
|
|
236
|
+
"""Detect dead-letter-exchange cycles across registered routes.
|
|
237
|
+
|
|
238
|
+
A queue's ``dead_letter_exchange`` points at an exchange; if that exchange
|
|
239
|
+
is the bind target of another route whose queue dead-letters back to an
|
|
240
|
+
exchange reachable from the first, messages loop forever (until TTL/GC).
|
|
241
|
+
This builds a queue→queue graph via DLX→exchange→bound-queue and rejects
|
|
242
|
+
any cycle with :class:`ConfigurationError`. Best-effort: only exchanges
|
|
243
|
+
named as a route's ``exchange`` are resolvable to a queue; unknown DLX
|
|
244
|
+
targets (external exchanges) are treated as sinks.
|
|
245
|
+
"""
|
|
246
|
+
from rabbitkit.core.errors import ConfigurationError as _CfgErr
|
|
247
|
+
|
|
248
|
+
# exchange name → list of queue names bound to it via a registered route
|
|
249
|
+
exchange_to_queues: dict[str, list[str]] = {}
|
|
250
|
+
for r in self._routes:
|
|
251
|
+
if r.exchange is not None:
|
|
252
|
+
exchange_to_queues.setdefault(r.exchange.name, []).append(r.queue.name)
|
|
253
|
+
|
|
254
|
+
# queue → next queues reachable via its DLX (through the exchange graph)
|
|
255
|
+
adj: dict[str, list[str]] = {}
|
|
256
|
+
for r in self._routes:
|
|
257
|
+
dlx = r.queue.dead_letter_exchange
|
|
258
|
+
if not dlx:
|
|
259
|
+
continue
|
|
260
|
+
nxts = exchange_to_queues.get(dlx, []) # unknown DLX → sink (no edges)
|
|
261
|
+
adj.setdefault(r.queue.name, []).extend(nxts)
|
|
262
|
+
|
|
263
|
+
# DFS cycle detection
|
|
264
|
+
white, gray, black = 0, 1, 2
|
|
265
|
+
color: dict[str, int] = {}
|
|
266
|
+
|
|
267
|
+
def dfs(node: str, path: list[str]) -> None:
|
|
268
|
+
color[node] = gray
|
|
269
|
+
for nxt in adj.get(node, []):
|
|
270
|
+
if color.get(nxt, white) == gray:
|
|
271
|
+
cycle = " -> ".join([*path, node, nxt])
|
|
272
|
+
raise _CfgErr(
|
|
273
|
+
f"Dead-letter-exchange cycle detected: {cycle}. "
|
|
274
|
+
"Messages would loop forever. Break the cycle (e.g. let "
|
|
275
|
+
"retry own DLQ topology, or point the final DLX at a sink "
|
|
276
|
+
"exchange with no bound queue)."
|
|
277
|
+
)
|
|
278
|
+
if color.get(nxt, white) == white:
|
|
279
|
+
dfs(nxt, [*path, node])
|
|
280
|
+
color[node] = black
|
|
281
|
+
|
|
282
|
+
for q in adj:
|
|
283
|
+
if color.get(q, white) == white:
|
|
284
|
+
dfs(q, [])
|