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,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
@@ -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
+ ...
@@ -0,0 +1,5 @@
1
+ """rabbitkit monitoring dashboard."""
2
+
3
+ from rabbitkit.dashboard.app import create_dashboard_app
4
+
5
+ __all__ = ["create_dashboard_app"]