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,261 @@
|
|
|
1
|
+
"""Exchange & Queue models with validation and declaration builders.
|
|
2
|
+
|
|
3
|
+
All topology validation happens here. Transport adapters call
|
|
4
|
+
to_declare_kwargs() / to_bind_kwargs() to get the appropriate
|
|
5
|
+
keyword arguments for pika or aio-pika declaration calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rabbitkit.core.types import ExchangeType, QueueType, validate_amqp_shortstr
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class RabbitExchange:
|
|
19
|
+
"""Exchange declaration model."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
type: ExchangeType = ExchangeType.DIRECT
|
|
23
|
+
durable: bool = True
|
|
24
|
+
auto_delete: bool = False
|
|
25
|
+
passive: bool = False
|
|
26
|
+
internal: bool = False
|
|
27
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
bind_to: str | None = None
|
|
29
|
+
bind_arguments: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
routing_key: str = ""
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
self.validate()
|
|
34
|
+
|
|
35
|
+
def validate(self) -> None:
|
|
36
|
+
"""Validate exchange configuration."""
|
|
37
|
+
if not self.name and self.type != ExchangeType.DIRECT:
|
|
38
|
+
msg = "Non-default exchanges must have a name"
|
|
39
|
+
raise ValueError(msg)
|
|
40
|
+
if self.internal and self.auto_delete:
|
|
41
|
+
msg = "Internal exchanges cannot be auto_delete (they are never published to directly)."
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
validate_amqp_shortstr("Exchange name", self.name)
|
|
44
|
+
validate_amqp_shortstr("Exchange routing_key", self.routing_key)
|
|
45
|
+
|
|
46
|
+
def to_declare_kwargs(self) -> dict[str, Any]:
|
|
47
|
+
"""Build exchange_declare kwargs for pika/aio-pika."""
|
|
48
|
+
return {
|
|
49
|
+
"exchange": self.name,
|
|
50
|
+
"exchange_type": self.type.value,
|
|
51
|
+
"durable": self.durable,
|
|
52
|
+
"auto_delete": self.auto_delete,
|
|
53
|
+
"passive": self.passive,
|
|
54
|
+
"internal": self.internal,
|
|
55
|
+
"arguments": self.arguments or None,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def to_bind_kwargs(self) -> dict[str, Any] | None:
|
|
59
|
+
"""Build exchange_bind kwargs. Returns None if no binding."""
|
|
60
|
+
if self.bind_to is None:
|
|
61
|
+
return None
|
|
62
|
+
return {
|
|
63
|
+
"destination": self.name,
|
|
64
|
+
"source": self.bind_to,
|
|
65
|
+
"routing_key": self.routing_key,
|
|
66
|
+
"arguments": self.bind_arguments or None,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class RabbitQueue:
|
|
72
|
+
"""Queue declaration model with type-specific validation."""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
durable: bool = True
|
|
76
|
+
exclusive: bool = False
|
|
77
|
+
passive: bool = False
|
|
78
|
+
auto_delete: bool = False
|
|
79
|
+
routing_key: str = ""
|
|
80
|
+
bind_arguments: dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
queue_type: QueueType = QueueType.CLASSIC
|
|
82
|
+
|
|
83
|
+
# DLQ
|
|
84
|
+
dead_letter_exchange: str | None = None
|
|
85
|
+
dead_letter_routing_key: str | None = None
|
|
86
|
+
|
|
87
|
+
# Limits
|
|
88
|
+
message_ttl: int | None = None # ms
|
|
89
|
+
max_length: int | None = None
|
|
90
|
+
max_length_bytes: int | None = None
|
|
91
|
+
|
|
92
|
+
# Classic-only
|
|
93
|
+
lazy: bool = False # x-queue-mode: lazy (classic only)
|
|
94
|
+
max_priority: int | None = None # classic only (0-255)
|
|
95
|
+
|
|
96
|
+
# Quorum-specific
|
|
97
|
+
delivery_limit: int | None = None # x-delivery-limit (quorum only)
|
|
98
|
+
single_active_consumer: bool = False # x-single-active-consumer
|
|
99
|
+
|
|
100
|
+
# Overflow
|
|
101
|
+
overflow: str | None = None # "drop-head" | "reject-publish" | "reject-publish-dlx"
|
|
102
|
+
|
|
103
|
+
# Expiry
|
|
104
|
+
expires: int | None = None # ms — auto-delete after idle
|
|
105
|
+
|
|
106
|
+
# Extra arguments (escape hatch)
|
|
107
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
self.validate()
|
|
111
|
+
|
|
112
|
+
def validate(self) -> None:
|
|
113
|
+
"""Enforce queue-type-specific constraints.
|
|
114
|
+
|
|
115
|
+
Raises ValueError for invalid combinations.
|
|
116
|
+
Uses warnings.warn() for unusual but legal combinations.
|
|
117
|
+
"""
|
|
118
|
+
if not self.name:
|
|
119
|
+
msg = "Queue name is required"
|
|
120
|
+
raise ValueError(msg)
|
|
121
|
+
validate_amqp_shortstr("Queue name", self.name)
|
|
122
|
+
validate_amqp_shortstr("Queue routing_key", self.routing_key)
|
|
123
|
+
|
|
124
|
+
# Quorum constraints
|
|
125
|
+
if self.queue_type == QueueType.QUORUM:
|
|
126
|
+
if not self.durable:
|
|
127
|
+
msg = "Quorum queues must be durable"
|
|
128
|
+
raise ValueError(msg)
|
|
129
|
+
if self.exclusive:
|
|
130
|
+
msg = "Quorum queues cannot be exclusive"
|
|
131
|
+
raise ValueError(msg)
|
|
132
|
+
if self.lazy:
|
|
133
|
+
msg = "Quorum queues do not support lazy mode (x-queue-mode)"
|
|
134
|
+
raise ValueError(msg)
|
|
135
|
+
if self.max_priority is not None:
|
|
136
|
+
msg = "Quorum queues do not support priorities"
|
|
137
|
+
raise ValueError(msg)
|
|
138
|
+
|
|
139
|
+
# Stream constraints
|
|
140
|
+
if self.queue_type == QueueType.STREAM:
|
|
141
|
+
if not self.durable:
|
|
142
|
+
msg = "Stream queues must be durable"
|
|
143
|
+
raise ValueError(msg)
|
|
144
|
+
if self.exclusive:
|
|
145
|
+
msg = "Stream queues cannot be exclusive"
|
|
146
|
+
raise ValueError(msg)
|
|
147
|
+
if self.lazy:
|
|
148
|
+
msg = "Stream queues do not support lazy mode"
|
|
149
|
+
raise ValueError(msg)
|
|
150
|
+
if self.max_priority is not None:
|
|
151
|
+
msg = "Stream queues do not support priorities"
|
|
152
|
+
raise ValueError(msg)
|
|
153
|
+
if self.message_ttl is not None:
|
|
154
|
+
msg = "Stream queues do not support message TTL"
|
|
155
|
+
raise ValueError(msg)
|
|
156
|
+
|
|
157
|
+
# Classic constraints
|
|
158
|
+
if self.queue_type == QueueType.CLASSIC:
|
|
159
|
+
if self.delivery_limit is not None:
|
|
160
|
+
msg = "Classic queues do not support delivery_limit (quorum only)"
|
|
161
|
+
raise ValueError(msg)
|
|
162
|
+
|
|
163
|
+
# Warnings for unusual combos
|
|
164
|
+
if self.lazy:
|
|
165
|
+
warnings.warn(
|
|
166
|
+
f"Queue '{self.name}': lazy=True sets the deprecated x-queue-mode=lazy "
|
|
167
|
+
"argument. RabbitMQ >=3.12 defaults classic queues to CQv2, which already "
|
|
168
|
+
"keeps message bodies out of memory in a lazy-like manner -- x-queue-mode "
|
|
169
|
+
"is a silent no-op there. On RabbitMQ <3.12 (or a classic queue explicitly "
|
|
170
|
+
"downgraded to v1) it still has effect. If you're targeting >=3.12, drop "
|
|
171
|
+
"lazy=True; the default queue behavior already covers this.",
|
|
172
|
+
UserWarning,
|
|
173
|
+
stacklevel=2,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if self.auto_delete and self.durable:
|
|
177
|
+
warnings.warn(
|
|
178
|
+
f"Queue '{self.name}': auto_delete=True with durable=True is unusual — "
|
|
179
|
+
"the queue will be deleted when the last consumer disconnects, "
|
|
180
|
+
"despite being durable",
|
|
181
|
+
UserWarning,
|
|
182
|
+
stacklevel=2,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if self.passive and any(
|
|
186
|
+
[
|
|
187
|
+
self.lazy,
|
|
188
|
+
self.max_priority is not None,
|
|
189
|
+
self.delivery_limit is not None,
|
|
190
|
+
self.message_ttl is not None,
|
|
191
|
+
self.max_length is not None,
|
|
192
|
+
]
|
|
193
|
+
):
|
|
194
|
+
warnings.warn(
|
|
195
|
+
f"Queue '{self.name}': passive=True with creation-only options set — "
|
|
196
|
+
"these options are ignored for passive declarations",
|
|
197
|
+
UserWarning,
|
|
198
|
+
stacklevel=2,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def to_declare_kwargs(self) -> dict[str, Any]:
|
|
202
|
+
"""Build queue_declare kwargs with merged x-arguments."""
|
|
203
|
+
args: dict[str, Any] = {}
|
|
204
|
+
|
|
205
|
+
# Queue type
|
|
206
|
+
args["x-queue-type"] = self.queue_type.value
|
|
207
|
+
|
|
208
|
+
# DLQ
|
|
209
|
+
if self.dead_letter_exchange is not None:
|
|
210
|
+
args["x-dead-letter-exchange"] = self.dead_letter_exchange
|
|
211
|
+
if self.dead_letter_routing_key is not None:
|
|
212
|
+
args["x-dead-letter-routing-key"] = self.dead_letter_routing_key
|
|
213
|
+
|
|
214
|
+
# Limits
|
|
215
|
+
if self.message_ttl is not None:
|
|
216
|
+
args["x-message-ttl"] = self.message_ttl
|
|
217
|
+
if self.max_length is not None:
|
|
218
|
+
args["x-max-length"] = self.max_length
|
|
219
|
+
if self.max_length_bytes is not None:
|
|
220
|
+
args["x-max-length-bytes"] = self.max_length_bytes
|
|
221
|
+
|
|
222
|
+
# Classic-only
|
|
223
|
+
if self.lazy:
|
|
224
|
+
args["x-queue-mode"] = "lazy"
|
|
225
|
+
if self.max_priority is not None:
|
|
226
|
+
args["x-max-priority"] = self.max_priority
|
|
227
|
+
|
|
228
|
+
# Quorum-specific
|
|
229
|
+
if self.delivery_limit is not None:
|
|
230
|
+
args["x-delivery-limit"] = self.delivery_limit
|
|
231
|
+
if self.single_active_consumer:
|
|
232
|
+
args["x-single-active-consumer"] = True
|
|
233
|
+
|
|
234
|
+
# Overflow
|
|
235
|
+
if self.overflow is not None:
|
|
236
|
+
args["x-overflow"] = self.overflow
|
|
237
|
+
|
|
238
|
+
# Expiry
|
|
239
|
+
if self.expires is not None:
|
|
240
|
+
args["x-expires"] = self.expires
|
|
241
|
+
|
|
242
|
+
# Merge user-provided arguments (escape hatch takes precedence)
|
|
243
|
+
args.update(self.arguments)
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
"queue": self.name,
|
|
247
|
+
"durable": self.durable,
|
|
248
|
+
"exclusive": self.exclusive,
|
|
249
|
+
"auto_delete": self.auto_delete,
|
|
250
|
+
"passive": self.passive,
|
|
251
|
+
"arguments": args,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def to_bind_kwargs(self, exchange: str) -> dict[str, Any]:
|
|
255
|
+
"""Build queue_bind kwargs."""
|
|
256
|
+
return {
|
|
257
|
+
"queue": self.name,
|
|
258
|
+
"exchange": exchange,
|
|
259
|
+
"routing_key": self.routing_key,
|
|
260
|
+
"arguments": self.bind_arguments or None,
|
|
261
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Shared topology-mode dispatch logic.
|
|
2
|
+
|
|
3
|
+
Both the sync (``sync/transport.py``) and async (``async_/transport.py``)
|
|
4
|
+
transports repeated the same ``TopologyMode`` conditional: skip on
|
|
5
|
+
``MANUAL``, passive-check on ``PASSIVE_ONLY`` / ``entity.passive``, else
|
|
6
|
+
active declare. That ~150 lines of duplicated decision logic now lives
|
|
7
|
+
here.
|
|
8
|
+
|
|
9
|
+
Design note — why a thin action-returning dispatcher (not callables):
|
|
10
|
+
The async transport's declare/get calls are coroutines, but lambdas
|
|
11
|
+
cannot ``await``. Rather than maintain sync/async callable variants,
|
|
12
|
+
this dispatcher decides *what to do* (returns a ``TopoAction``) and lets
|
|
13
|
+
each transport perform the actual sync/async channel call. This keeps
|
|
14
|
+
the dispatcher sync-only, transport-agnostic, and free of pika/aio-pika
|
|
15
|
+
imports (preserving the ``core/`` zero-transport-import invariant).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from enum import Enum, auto
|
|
21
|
+
|
|
22
|
+
from rabbitkit.core.topology import RabbitExchange, RabbitQueue
|
|
23
|
+
from rabbitkit.core.types import TopologyMode
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TopoAction(Enum):
|
|
27
|
+
"""What the transport should do for a topology entity."""
|
|
28
|
+
|
|
29
|
+
SKIP = auto() # TopologyMode.MANUAL — do nothing
|
|
30
|
+
PASSIVE = auto() # PASSIVE_ONLY or entity.passive — passive existence check
|
|
31
|
+
DECLARE = auto() # active declaration
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TopologyDispatcher:
|
|
35
|
+
"""Resolves ``TopologyMode`` into a concrete ``TopoAction`` per entity.
|
|
36
|
+
|
|
37
|
+
The transport computes ``to_declare_kwargs()`` and performs the actual
|
|
38
|
+
(sync or async) channel call based on the returned ``TopoAction``,
|
|
39
|
+
keeping all transport-specific I/O out of this class.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, mode: TopologyMode) -> None:
|
|
43
|
+
self._mode = mode
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def mode(self) -> TopologyMode:
|
|
47
|
+
"""The topology mode this dispatcher was configured with."""
|
|
48
|
+
return self._mode
|
|
49
|
+
|
|
50
|
+
def exchange_action(self, exchange: RabbitExchange) -> TopoAction:
|
|
51
|
+
"""Action to take for ``declare_exchange(exchange)``."""
|
|
52
|
+
if self._mode == TopologyMode.MANUAL:
|
|
53
|
+
return TopoAction.SKIP
|
|
54
|
+
if self._mode == TopologyMode.PASSIVE_ONLY or exchange.passive:
|
|
55
|
+
return TopoAction.PASSIVE
|
|
56
|
+
return TopoAction.DECLARE
|
|
57
|
+
|
|
58
|
+
def queue_action(self, queue: RabbitQueue) -> TopoAction:
|
|
59
|
+
"""Action to take for ``declare_queue(queue)``."""
|
|
60
|
+
if self._mode == TopologyMode.MANUAL:
|
|
61
|
+
return TopoAction.SKIP
|
|
62
|
+
if self._mode == TopologyMode.PASSIVE_ONLY or queue.passive:
|
|
63
|
+
return TopoAction.PASSIVE
|
|
64
|
+
return TopoAction.DECLARE
|
|
65
|
+
|
|
66
|
+
def binding_action(self) -> TopoAction:
|
|
67
|
+
"""Action to take for ``bind_queue`` / ``bind_exchange``.
|
|
68
|
+
|
|
69
|
+
Bindings have no passive variant — they are skipped only under
|
|
70
|
+
``MANUAL`` and performed otherwise.
|
|
71
|
+
"""
|
|
72
|
+
if self._mode == TopologyMode.MANUAL:
|
|
73
|
+
return TopoAction.SKIP
|
|
74
|
+
return TopoAction.DECLARE
|
rabbitkit/core/types.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Core enums and data types — SINGLE CANONICAL LOCATION for all enums.
|
|
2
|
+
|
|
3
|
+
Every enum, value object, and core data type lives here.
|
|
4
|
+
Imported everywhere else — never duplicated.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from rabbitkit.core.message import RabbitMessage
|
|
16
|
+
|
|
17
|
+
# ── AMQP protocol-level constants ────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
# RabbitMQ's "direct reply-to" pseudo-queue (an AMQP protocol feature, not a
|
|
20
|
+
# rabbitkit invention). It has two hard broker rules: consuming from it
|
|
21
|
+
# requires a no-ack consumer, and the broker rejects any Queue.Declare against
|
|
22
|
+
# it (active or passive). A more subtle rule transports must also honor:
|
|
23
|
+
# publishing a request with reply_to=DIRECT_REPLY_TO_QUEUE must happen on the
|
|
24
|
+
# SAME channel that registered the reply consumer — otherwise the broker
|
|
25
|
+
# raises "PRECONDITION_FAILED - fast reply consumer does not exist" on
|
|
26
|
+
# publish. Single canonical constant so rpc.py and both transports agree.
|
|
27
|
+
DIRECT_REPLY_TO_QUEUE = "amq.rabbitmq.reply-to"
|
|
28
|
+
|
|
29
|
+
# AMQP 0-9-1 encodes exchange names, queue names, and routing keys as
|
|
30
|
+
# shortstr: a 1-byte length prefix, so 255 bytes is a hard protocol ceiling
|
|
31
|
+
# (not a RabbitMQ convention). Exceeding it previously surfaced as an opaque
|
|
32
|
+
# frame-encoding error from the client library or a connection-level
|
|
33
|
+
# PRECONDITION_FAILED from the broker at declare/publish time, far from the
|
|
34
|
+
# line that actually set the oversized value.
|
|
35
|
+
AMQP_SHORTSTR_MAX_BYTES = 255
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_amqp_shortstr(field_name: str, value: str) -> None:
|
|
39
|
+
"""Raise ``ValueError`` if ``value`` exceeds the AMQP shortstr limit.
|
|
40
|
+
|
|
41
|
+
Length is measured in encoded UTF-8 bytes (the wire unit), not
|
|
42
|
+
characters -- a 255-character string using multi-byte code points can
|
|
43
|
+
already be oversized.
|
|
44
|
+
"""
|
|
45
|
+
encoded_len = len(value.encode("utf-8"))
|
|
46
|
+
if encoded_len > AMQP_SHORTSTR_MAX_BYTES:
|
|
47
|
+
msg = (
|
|
48
|
+
f"{field_name} is {encoded_len} bytes, exceeding the AMQP shortstr "
|
|
49
|
+
f"limit of {AMQP_SHORTSTR_MAX_BYTES} bytes: {value[:40]!r}..."
|
|
50
|
+
)
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _RequeuedForRetrySentinel:
|
|
55
|
+
"""Sentinel type for :data:`REQUEUED_FOR_RETRY` (H8)."""
|
|
56
|
+
|
|
57
|
+
__slots__ = ()
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return "REQUEUED_FOR_RETRY"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# H8: returned by RetryMiddleware.consume_scope/consume_scope_async instead of
|
|
64
|
+
# ``None`` whenever a handler failure was routed for retry (delay-queue
|
|
65
|
+
# publish, or nack(requeue=True) if that publish itself failed) rather than
|
|
66
|
+
# actually succeeding. RetryMiddleware swallows the handler's exception in
|
|
67
|
+
# this case (by design — an OUTER ExceptionMiddleware must not treat a
|
|
68
|
+
# retry-in-progress as a terminal failure), so from an outer middleware's
|
|
69
|
+
# point of view, ``call_next(message)`` returns normally either way. That is
|
|
70
|
+
# indistinguishable from "the handler ran and returned None" UNLESS the
|
|
71
|
+
# outer middleware checks for this sentinel — which matters concretely for
|
|
72
|
+
# DeduplicationMiddleware(mark_policy="on_success"): without checking, it
|
|
73
|
+
# would mark the message as processed on a failed-then-retried attempt, so
|
|
74
|
+
# the later retry redelivery (same dedup key) is dropped as a duplicate and
|
|
75
|
+
# never actually processed (silent message loss). Any custom middleware
|
|
76
|
+
# wrapping a route that may contain a RetryMiddleware should treat a
|
|
77
|
+
# ``call_next`` result identical to this sentinel (``is REQUEUED_FOR_RETRY``)
|
|
78
|
+
# as "not yet done, expect another delivery" rather than "succeeded."
|
|
79
|
+
REQUEUED_FOR_RETRY = _RequeuedForRetrySentinel()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AppState(str, Enum):
|
|
83
|
+
"""Application lifecycle states.
|
|
84
|
+
|
|
85
|
+
Canonical home for this enum is ``core/types.py`` per the project rule that
|
|
86
|
+
``types.py`` is the SINGLE canonical location for all enums and data types.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
IDLE = "idle"
|
|
90
|
+
STARTING = "starting"
|
|
91
|
+
RUNNING = "running"
|
|
92
|
+
STOPPING = "stopping"
|
|
93
|
+
STOPPED = "stopped"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ExchangeType(str, Enum):
|
|
97
|
+
"""AMQP exchange types."""
|
|
98
|
+
|
|
99
|
+
DIRECT = "direct"
|
|
100
|
+
FANOUT = "fanout"
|
|
101
|
+
TOPIC = "topic"
|
|
102
|
+
HEADERS = "headers"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class QueueType(str, Enum):
|
|
106
|
+
"""RabbitMQ queue types."""
|
|
107
|
+
|
|
108
|
+
CLASSIC = "classic"
|
|
109
|
+
QUORUM = "quorum"
|
|
110
|
+
STREAM = "stream"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AckPolicy(str, Enum):
|
|
114
|
+
"""Message acknowledgement policies.
|
|
115
|
+
|
|
116
|
+
See Contract 1 in the plan for exact semantics:
|
|
117
|
+
- AUTO: success→ack, exception→classify→nack/reject
|
|
118
|
+
- MANUAL: handler owns ack/nack/reject entirely
|
|
119
|
+
- NACK_ON_ERROR: success→ack, exception→nack(requeue=False)
|
|
120
|
+
- ACK_FIRST: ack BEFORE handler runs (at-most-once)
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
AUTO = "auto"
|
|
124
|
+
MANUAL = "manual"
|
|
125
|
+
NACK_ON_ERROR = "nack_on_error"
|
|
126
|
+
ACK_FIRST = "ack_first"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class DeduplicationMarkPolicy(str, Enum):
|
|
130
|
+
"""When DeduplicationMiddleware records the dedup key.
|
|
131
|
+
|
|
132
|
+
- ON_SUCCESS (default): check before the handler (no write), mark only
|
|
133
|
+
after it succeeds. Crash-safe; concurrent duplicates may both process.
|
|
134
|
+
- ON_START: mark before the handler. Blocks concurrent duplicates but a
|
|
135
|
+
crash mid-handler LOSES the message (redelivery is skipped as a
|
|
136
|
+
duplicate). Advanced/dangerous — use only when duplicate execution is
|
|
137
|
+
worse than message loss.
|
|
138
|
+
- CLAIM: two-state — an "in-flight" claim (expires after
|
|
139
|
+
``processing_timeout``) before the handler, flipped to "completed"
|
|
140
|
+
(full ``ttl``) on success. Blocks concurrent duplicates AND survives
|
|
141
|
+
crashes, provided ``processing_timeout`` comfortably exceeds the
|
|
142
|
+
worst-case handler duration.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
ON_SUCCESS = "on_success"
|
|
146
|
+
ON_START = "on_start"
|
|
147
|
+
CLAIM = "claim"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RejectWithoutDLXPolicy(str, Enum):
|
|
151
|
+
"""What to do when a route can ``reject(requeue=False)`` but its queue
|
|
152
|
+
has no dead-letter exchange (RabbitMQ silently DISCARDS such rejects).
|
|
153
|
+
|
|
154
|
+
- AUTO_PROVISION (default): declare ``{queue}.dlq`` and wire the source
|
|
155
|
+
queue's DLX to it (default exchange + queue-name routing, same
|
|
156
|
+
convention as retry topology). Safe by default — a poison message
|
|
157
|
+
lands in the DLQ instead of vanishing.
|
|
158
|
+
- ERROR: refuse to start — raises ``UnsafeTopologyError``. For teams
|
|
159
|
+
that manage topology externally and want unsafe config to fail fast.
|
|
160
|
+
- DISCARD: explicitly allow RabbitMQ to discard rejected messages
|
|
161
|
+
(warns once per route). For low-value/ephemeral workloads only.
|
|
162
|
+
|
|
163
|
+
Applied only under ``TopologyMode.AUTO_DECLARE`` — in PASSIVE_ONLY and
|
|
164
|
+
MANUAL modes rabbitkit does not own queue arguments and cannot know
|
|
165
|
+
whether an externally-managed DLX exists.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
AUTO_PROVISION = "auto_provision"
|
|
169
|
+
ERROR = "error"
|
|
170
|
+
DISCARD = "discard"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TopologyMode(str, Enum):
|
|
174
|
+
"""Topology declaration modes.
|
|
175
|
+
|
|
176
|
+
See Contract 6 in the plan for precedence rules:
|
|
177
|
+
- AUTO_DECLARE: declare exchanges/queues/bindings on startup
|
|
178
|
+
- PASSIVE_ONLY: all declarations use passive=True
|
|
179
|
+
- MANUAL: skip all topology operations
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
AUTO_DECLARE = "auto_declare"
|
|
183
|
+
PASSIVE_ONLY = "passive_only"
|
|
184
|
+
MANUAL = "manual"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ErrorSeverity(str, Enum):
|
|
188
|
+
"""Error classification severity levels."""
|
|
189
|
+
|
|
190
|
+
TRANSIENT = "transient"
|
|
191
|
+
PERMANENT = "permanent"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PublishStatus(str, Enum):
|
|
195
|
+
"""Result status of a publish operation."""
|
|
196
|
+
|
|
197
|
+
CONFIRMED = "confirmed"
|
|
198
|
+
#: M4: fire-and-forget publish (PublisherConfig.confirm_delivery=False)
|
|
199
|
+
#: -- written to the socket, but the broker never acknowledged it.
|
|
200
|
+
#: Distinct from CONFIRMED so code that specifically needs a real
|
|
201
|
+
#: broker ack (e.g. deciding whether it's safe to ack/discard a source
|
|
202
|
+
#: message after republishing it, as retry/result publishing do) can
|
|
203
|
+
#: tell the two apart via ``.status`` instead of being told "confirmed"
|
|
204
|
+
#: when nothing was actually confirmed.
|
|
205
|
+
SENT = "sent"
|
|
206
|
+
NACKED = "nacked"
|
|
207
|
+
TIMEOUT = "timeout"
|
|
208
|
+
RETURNED = "returned"
|
|
209
|
+
ERROR = "error"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True, slots=True)
|
|
213
|
+
class PublishOutcome:
|
|
214
|
+
"""Result of a publish operation."""
|
|
215
|
+
|
|
216
|
+
status: PublishStatus
|
|
217
|
+
delivery_tag: int | None = None
|
|
218
|
+
exchange: str = ""
|
|
219
|
+
routing_key: str = ""
|
|
220
|
+
error: BaseException | None = None
|
|
221
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def ok(self) -> bool:
|
|
225
|
+
"""True if the publish did not fail -- CONFIRMED (broker
|
|
226
|
+
acknowledged it) or SENT (fire-and-forget, confirm_delivery=False --
|
|
227
|
+
written to the socket but never broker-confirmed).
|
|
228
|
+
|
|
229
|
+
M4: if you specifically need to know the broker actually confirmed
|
|
230
|
+
the message (e.g. before treating a republish as durable enough to
|
|
231
|
+
settle/discard the original), check ``status ==
|
|
232
|
+
PublishStatus.CONFIRMED`` directly -- ``.ok`` alone can't
|
|
233
|
+
distinguish "confirmed" from "sent, unconfirmed."
|
|
234
|
+
"""
|
|
235
|
+
return self.status in (PublishStatus.CONFIRMED, PublishStatus.SENT)
|
|
236
|
+
|
|
237
|
+
def raise_for_status(self) -> PublishOutcome:
|
|
238
|
+
"""Raise ``PublishError`` if the publish failed; else return self (M1).
|
|
239
|
+
|
|
240
|
+
``broker.publish()`` never raises — it returns this outcome so a
|
|
241
|
+
failed publish (NACKED / TIMEOUT / RETURNED / ERROR) can't be lost by
|
|
242
|
+
code that simply ignores the return value. Callers who prefer
|
|
243
|
+
exceptions opt in::
|
|
244
|
+
|
|
245
|
+
broker.publish(envelope).raise_for_status()
|
|
246
|
+
|
|
247
|
+
Chains so ``outcome = broker.publish(...).raise_for_status()`` works.
|
|
248
|
+
"""
|
|
249
|
+
if not self.ok:
|
|
250
|
+
from rabbitkit.core.errors import PublishError
|
|
251
|
+
|
|
252
|
+
raise PublishError(self)
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True, slots=True)
|
|
257
|
+
class MessageEnvelope:
|
|
258
|
+
"""Outgoing message envelope.
|
|
259
|
+
|
|
260
|
+
NOTE: AMQP header values are limited to:
|
|
261
|
+
str, int, float, bool, bytes, datetime, Decimal, list/dict of these, or None.
|
|
262
|
+
Arbitrary Python objects (sets, custom classes) will raise at publish time.
|
|
263
|
+
Transport validates header values before sending.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
routing_key: str
|
|
267
|
+
body: bytes
|
|
268
|
+
exchange: str = ""
|
|
269
|
+
headers: dict[str, Any] = field(default_factory=dict)
|
|
270
|
+
message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
271
|
+
correlation_id: str | None = None
|
|
272
|
+
reply_to: str | None = None
|
|
273
|
+
timestamp: datetime | None = None
|
|
274
|
+
content_type: str = "application/json"
|
|
275
|
+
content_encoding: str | None = None
|
|
276
|
+
expiration: str | None = None
|
|
277
|
+
priority: int | None = None
|
|
278
|
+
mandatory: bool = False
|
|
279
|
+
delivery_mode: int = 2 # 1=transient, 2=persistent
|
|
280
|
+
type: str | None = None
|
|
281
|
+
user_id: str | None = None
|
|
282
|
+
app_id: str | None = None
|
|
283
|
+
|
|
284
|
+
def __post_init__(self) -> None:
|
|
285
|
+
# Catches an oversized routing_key/exchange at construction time --
|
|
286
|
+
# the same choke point every publish (broker.publish, retry
|
|
287
|
+
# republish, DLQ replay, batch) goes through -- instead of an
|
|
288
|
+
# opaque broker connection error later.
|
|
289
|
+
validate_amqp_shortstr("routing_key", self.routing_key)
|
|
290
|
+
validate_amqp_shortstr("exchange", self.exchange)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True, slots=True)
|
|
294
|
+
class ClassifiedError:
|
|
295
|
+
"""Result of error classification."""
|
|
296
|
+
|
|
297
|
+
severity: ErrorSeverity
|
|
298
|
+
original: BaseException
|
|
299
|
+
reason: str
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@runtime_checkable
|
|
303
|
+
class AckStrategy(Protocol):
|
|
304
|
+
"""Settlement strategy for an ``AckPolicy``.
|
|
305
|
+
|
|
306
|
+
Each strategy owns the success-path ack and the error-path settlement.
|
|
307
|
+
Handler-raised ``AckMessage`` / ``NackMessage`` / ``RejectMessage`` are
|
|
308
|
+
NOT policy-driven and stay in the pipeline.
|
|
309
|
+
|
|
310
|
+
See Contract 1 in the plan for per-policy semantics.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def acks_first(self) -> bool:
|
|
315
|
+
"""True when the message is acked BEFORE the handler runs (ACK_FIRST)."""
|
|
316
|
+
...
|
|
317
|
+
|
|
318
|
+
def on_success(self, msg: RabbitMessage) -> None:
|
|
319
|
+
"""Settle the message after a successful handler invocation."""
|
|
320
|
+
...
|
|
321
|
+
|
|
322
|
+
def on_error(self, msg: RabbitMessage, exc: Exception) -> None:
|
|
323
|
+
"""Settle the message after an unhandled handler exception."""
|
|
324
|
+
...
|