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,849 @@
1
+ """Focused configuration objects — composable, immutable, validated.
2
+
3
+ Split into focused dataclasses. Each has clear responsibility.
4
+ RabbitConfig only composes connection/broker defaults.
5
+ Throughput/batching config objects are accepted by their respective components.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, field
13
+ from urllib.parse import parse_qs, quote, unquote, urlparse
14
+
15
+ from rabbitkit.core.logging import LoggingConfig
16
+ from rabbitkit.core.types import ErrorSeverity, TopologyMode
17
+
18
+ # ── Connection ─────────────────────────────────────────────────────────────
19
+
20
+
21
+ def _masked_repr(obj: object, *, secret_fields: tuple[str, ...] = ("password",)) -> str:
22
+ """L2: generic ``__repr__`` for a config dataclass that masks *secret_fields*.
23
+
24
+ The default dataclass-generated ``__repr__`` includes every field
25
+ verbatim — any log line or traceback that reprs a config object (or logs
26
+ it directly, which falls back to ``__repr__``) leaks the plaintext
27
+ password. Iterates ``dataclasses.fields()`` generically so a field added
28
+ later is still included (just not masked unless also listed).
29
+ """
30
+ import dataclasses
31
+
32
+ parts = []
33
+ for f in dataclasses.fields(obj): # type: ignore[arg-type]
34
+ value = "'***'" if f.name in secret_fields else repr(getattr(obj, f.name))
35
+ parts.append(f"{f.name}={value}")
36
+ return f"{type(obj).__name__}({', '.join(parts)})"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ConnectionConfig:
41
+ """Core connection parameters."""
42
+
43
+ host: str = "localhost"
44
+ port: int = 5672
45
+ username: str = "guest"
46
+ password: str = "guest"
47
+ vhost: str = "/"
48
+ heartbeat: int = 30
49
+ socket_timeout: float = 10.0
50
+ # k8s-friendly default: fail fast on a blocked connection rather than
51
+ # appearing healthy for 5 minutes while publishing stalls.
52
+ blocked_connection_timeout: float = 60.0
53
+ connection_name: str | None = None
54
+ reconnect_backoff_base: float = 1.0
55
+ reconnect_backoff_max: float = 30.0
56
+ # M13: credential rotation. When set, called at each (re)connect to fetch
57
+ # fresh (username, password) — e.g. from Vault/short-lived secrets — so a
58
+ # rotated credential is picked up on the next reconnect WITHOUT a redeploy
59
+ # (the frozen username/password fields are the static fallback). Excluded
60
+ # from the AMQP URL/repr paths; only the connection builders call it.
61
+ credentials_provider: Callable[[], tuple[str, str]] | None = None
62
+ # M9: additional cluster nodes for failover, each "host" or "host:port".
63
+ # The primary (host/port above) is tried first, then each node in order.
64
+ # All share the same credentials/vhost/TLS/heartbeat. Sync (pika) tries
65
+ # them natively via a ConnectionParameters list; async cycles endpoints on
66
+ # initial connect (connect_robust then pins to the chosen node — put a
67
+ # load balancer / DNS round-robin in front for per-reconnect failover).
68
+ nodes: tuple[str, ...] = ()
69
+
70
+ def resolve_credentials(self) -> tuple[str, str]:
71
+ """Return ``(username, password)`` — from ``credentials_provider`` if
72
+ set (M13: called at each (re)connect so rotated secrets are picked up
73
+ without a redeploy), else the static ``username``/``password`` fields."""
74
+ if self.credentials_provider is not None:
75
+ return self.credentials_provider()
76
+ return self.username, self.password
77
+
78
+ def cluster_endpoints(self) -> list[tuple[str, int]]:
79
+ """Primary host:port plus any failover nodes, in connect-attempt order."""
80
+ endpoints = [(self.host, self.port)]
81
+ for node in self.nodes:
82
+ if ":" in node:
83
+ host, _, port = node.partition(":")
84
+ endpoints.append((host, int(port)))
85
+ else:
86
+ endpoints.append((node, self.port))
87
+ return endpoints
88
+
89
+ def __repr__(self) -> str:
90
+ return _masked_repr(self)
91
+
92
+ @property
93
+ def url(self) -> str:
94
+ """Build AMQP URL from config fields.
95
+
96
+ Username, password and vhost are URL-encoded so credentials containing
97
+ reserved characters (``@:/#`` etc.) cannot corrupt the host/port parse
98
+ (mirrors the encoding done in ``async_/connection.py``).
99
+
100
+ SECURITY (L2): this embeds the plaintext password (``user:pass@host``)
101
+ — never log or repr it. Use :attr:`safe_url` for logging/display.
102
+ """
103
+ if self.vhost == "/":
104
+ vhost = "%2F"
105
+ else:
106
+ vhost = quote(self.vhost, safe="")
107
+ user = quote(self.username, safe="")
108
+ pwd = quote(self.password, safe="")
109
+ return f"amqp://{user}:{pwd}@{self.host}:{self.port}/{vhost}"
110
+
111
+ @property
112
+ def safe_url(self) -> str:
113
+ """Like :attr:`url` but with the password masked (L2) — safe to log."""
114
+ if self.vhost == "/":
115
+ vhost = "%2F"
116
+ else:
117
+ vhost = quote(self.vhost, safe="")
118
+ user = quote(self.username, safe="")
119
+ return f"amqp://{user}:***@{self.host}:{self.port}/{vhost}"
120
+
121
+ def __post_init__(self) -> None:
122
+ # Surface the insecure default-credentials-against-non-local-host mistake
123
+ # once at construction (not per-connection). The default is kept for dev
124
+ # convenience; this only warns.
125
+ if self.username == "guest" and self.host not in {"localhost", "127.0.0.1", "::1"}:
126
+ warnings.warn(
127
+ "ConnectionConfig uses default 'guest' credentials against non-local "
128
+ f"host {self.host!r}; set explicit username/password for production.",
129
+ UserWarning,
130
+ stacklevel=2,
131
+ )
132
+ # M9: fail fast on a malformed node entry rather than at connect time.
133
+ for node in self.nodes:
134
+ _, sep, port = node.partition(":")
135
+ if sep and not port.isdigit():
136
+ raise ValueError(
137
+ f"ConnectionConfig.nodes entry {node!r} has a non-numeric port; "
138
+ "use 'host' or 'host:port'."
139
+ )
140
+
141
+ @classmethod
142
+ def from_url(cls, url: str) -> ConnectionConfig:
143
+ """Parse an AMQP URL into a ConnectionConfig.
144
+
145
+ Supports: amqp://user:pass@host:port/vhost?heartbeat=30&connection_timeout=10
146
+ """
147
+ parsed = urlparse(url)
148
+ query = parse_qs(parsed.query)
149
+
150
+ vhost = parsed.path.lstrip("/") if parsed.path and parsed.path != "/" else "/"
151
+ if vhost == "%2F" or vhost == "":
152
+ vhost = "/"
153
+
154
+ # M3: an amqps:// URL implies TLS, but rabbitkit enables TLS via
155
+ # SecurityConfig(ssl=SSLConfig(enabled=True)), NOT the URL scheme —
156
+ # ConnectionConfig carries no TLS state. Silently ignoring the scheme
157
+ # would let an operator ship PLAINTEXT while believing TLS is on. We
158
+ # default the port to the AMQPS port (5671) and warn loudly, so a
159
+ # not-actually-encrypted connection fails fast against a TLS-only port
160
+ # instead of leaking plaintext to a plaintext listener.
161
+ is_amqps = parsed.scheme == "amqps"
162
+ default_port = 5671 if is_amqps else 5672
163
+ if is_amqps:
164
+ import warnings
165
+
166
+ warnings.warn(
167
+ "amqps:// URL parsed, but rabbitkit does not enable TLS from the URL "
168
+ "scheme — you must pass SecurityConfig(ssl=SSLConfig(enabled=True)) to "
169
+ "RabbitConfig. Port defaulted to 5671; without TLS enabled this "
170
+ "connection will fail against a TLS-only listener (not silently send "
171
+ "plaintext).",
172
+ RuntimeWarning,
173
+ stacklevel=2,
174
+ )
175
+
176
+ kwargs: dict[str, str | int | float | None] = {
177
+ "host": parsed.hostname or "localhost",
178
+ "port": parsed.port or default_port,
179
+ # Percent-decode credentials so an encoded AMQP URL round-trips
180
+ # without double-encoding (e.g. user%40 -> user@).
181
+ "username": unquote(parsed.username) if parsed.username else "guest",
182
+ "password": unquote(parsed.password) if parsed.password else "guest",
183
+ "vhost": vhost,
184
+ }
185
+
186
+ if "heartbeat" in query:
187
+ kwargs["heartbeat"] = int(query["heartbeat"][0])
188
+ if "connection_timeout" in query:
189
+ kwargs["socket_timeout"] = float(query["connection_timeout"][0])
190
+ if "blocked_connection_timeout" in query:
191
+ kwargs["blocked_connection_timeout"] = float(query["blocked_connection_timeout"][0])
192
+
193
+ return cls(**kwargs) # type: ignore[arg-type]
194
+
195
+
196
+ # ── TCP/Socket ─────────────────────────────────────────────────────────────
197
+
198
+
199
+ @dataclass(frozen=True, slots=True)
200
+ class SocketConfig:
201
+ """Low-level TCP tuning.
202
+
203
+ Applied best-effort — not all options are universally guaranteed
204
+ depending on OS and backend internals.
205
+ """
206
+
207
+ tcp_nodelay: bool = True
208
+ tcp_keepidle: int = 10
209
+ tcp_keepintvl: int = 5
210
+ tcp_keepcnt: int = 3
211
+ tcp_sndbuf: int = 196608 # 192KB
212
+ tcp_rcvbuf: int = 196608 # 192KB
213
+
214
+
215
+ # ── SSL/TLS ────────────────────────────────────────────────────────────────
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class SSLConfig:
220
+ """TLS/SSL configuration."""
221
+
222
+ enabled: bool = False
223
+ certfile: str | None = None
224
+ keyfile: str | None = None
225
+ ca_certs: str | None = None
226
+ cert_reqs: str = "CERT_REQUIRED"
227
+ server_hostname: str | None = None
228
+
229
+
230
+ # ── Security ───────────────────────────────────────────────────────────────
231
+
232
+
233
+ @dataclass(frozen=True, slots=True)
234
+ class SecurityConfig:
235
+ """SASL + authentication configuration.
236
+
237
+ Only ``mechanism="PLAIN"`` is implemented (username/password). ``EXTERNAL``
238
+ (x509 client-cert auth) is not wired into either transport, so accepting
239
+ it silently would be "config that lies" — it is rejected at construction
240
+ (M2). mTLS is still supported for transport *encryption* via
241
+ ``SSLConfig(certfile=..., keyfile=...)``; it just isn't an auth mechanism.
242
+ """
243
+
244
+ mechanism: str = "PLAIN"
245
+ ssl: SSLConfig = field(default_factory=SSLConfig)
246
+
247
+ def __post_init__(self) -> None:
248
+ if self.mechanism != "PLAIN":
249
+ raise ValueError(
250
+ f"SecurityConfig.mechanism={self.mechanism!r} is not supported — only "
251
+ "'PLAIN' (username/password) is implemented. SASL EXTERNAL/x509-auth is "
252
+ "not wired into the transports. For TLS client certs (encryption, not "
253
+ "auth), use SSLConfig(certfile=..., keyfile=...)."
254
+ )
255
+
256
+
257
+ # ── Publisher ──────────────────────────────────────────────────────────────
258
+
259
+
260
+ @dataclass(frozen=True, slots=True)
261
+ class PublisherConfig:
262
+ """Publisher behavior tuning."""
263
+
264
+ exchange: str = ""
265
+ confirm_delivery: bool = True
266
+ confirm_timeout: float = 5.0
267
+ mandatory: bool = False
268
+ persistent: bool = True
269
+ # M10: reject oversized message bodies at publish time (bytes). 0 =
270
+ # disabled (default, unchanged). Large messages are a RabbitMQ anti-pattern
271
+ # (memory pressure, head-of-line blocking, slow recovery) — set a cap
272
+ # (e.g. 1_048_576 for 1 MiB) to fail fast on a programming error instead
273
+ # of shipping a 50 MB message. Enforced by broker.publish() → ValueError.
274
+ max_message_bytes: int = 0
275
+
276
+
277
+ # ── Consumer ───────────────────────────────────────────────────────────────
278
+
279
+
280
+ @dataclass(frozen=True, slots=True)
281
+ class ConsumerConfig:
282
+ """Consumer behavior tuning."""
283
+
284
+ prefetch_count: int = 10
285
+ graceful_timeout: float = 30.0
286
+ # M6: bound the transient hot-loop on retry-less AUTO routes. By default
287
+ # (False), a transient error nack-requeues with no cap — legitimate
288
+ # "wait for the downstream to recover" behavior, but a footgun when the
289
+ # failure is really permanent. When True, a transient error on a message
290
+ # the broker has ALREADY redelivered (redelivered=True) is rejected to the
291
+ # DLQ instead of requeued again — a 2-strike cap using the broker's
292
+ # redelivered flag (the only per-message redelivery signal available
293
+ # without republishing; classic-queue requeues can't carry a count). For
294
+ # a higher cap or delays, use retry (delay ladder) or a quorum source
295
+ # queue with x-delivery-limit. Requires a dead-letter path — the default
296
+ # reject_without_dlx="auto_provision" (C3) gives AUTO routes one.
297
+ reject_transient_on_redelivery: bool = False
298
+
299
+
300
+ # ── Pool ───────────────────────────────────────────────────────────────────
301
+
302
+
303
+ @dataclass(frozen=True, slots=True)
304
+ class PoolConfig:
305
+ """Connection and channel pool sizing.
306
+
307
+ ``channel_pool_size`` (publisher channel pool) and ``channel_acquire_timeout``
308
+ are active. ``publisher_connections`` / ``consumer_connections`` are
309
+ **reserved**: the transport currently uses one connection per role, because
310
+ multiple connections sharing a single event loop showed no throughput benefit
311
+ in benchmarks (the loop is the bound). Scale throughput by running more
312
+ processes/pods, not more connections per process.
313
+ """
314
+
315
+ channel_pool_size: int = 10
316
+ publisher_connections: int = 1 # reserved — see class docstring
317
+ consumer_connections: int = 1 # reserved — see class docstring
318
+ channel_acquire_timeout: float = 10.0 # seconds to wait for a pooled channel
319
+ prewarm_channels: bool = False # pre-create all pool channels on connect() to eliminate warmup jitter
320
+
321
+
322
+ # ── Retry ──────────────────────────────────────────────────────────────────
323
+
324
+
325
+ @dataclass(frozen=True, slots=True)
326
+ class RetryConfig:
327
+ """Retry with delay queues configuration.
328
+
329
+ Accepted by RetryMiddleware. Can be set as broker default
330
+ (RabbitConfig.retry) or per-route override (route.retry_override).
331
+
332
+ ``delays`` must have at least ``max_retries`` entries. Extra retries
333
+ beyond the length of ``delays`` would silently reuse the last delay,
334
+ which is almost always a misconfiguration.
335
+ """
336
+
337
+ max_retries: int = 4
338
+ delays: tuple[int, ...] = (5, 30, 120, 600)
339
+ retry_header: str = "x-rabbitkit-retry-count"
340
+ # RESERVED / no-op (M2): queue-based retry uses a fixed per-queue
341
+ # x-message-ttl, so jitter would require per-message TTL — which
342
+ # reintroduces head-of-line blocking on classic queues. Kept for API
343
+ # stability; does not affect delay timing. Spread retries across a fleet
344
+ # via the per-process reconnect jitter, not this.
345
+ jitter_factor: float = 0.1
346
+ # F4: "off" (default — single delay queue per tier, exact legacy topology)
347
+ # or "sharded" — each tier becomes jitter_shards sub-queues with uniform
348
+ # TTLs staggered across ±jitter_factor; a message picks its shard by a
349
+ # STABLE hash of its message_id, decorrelating retry waves across the
350
+ # fleet WITHOUT per-message TTL (which would reintroduce classic-queue
351
+ # head-of-line blocking). Shard 0 keeps the legacy queue name/TTL, so
352
+ # enabling this on an existing topology is additive (no 406s).
353
+ jitter_mode: str = "off"
354
+ jitter_shards: int = 3
355
+ dead_letter_exchange: str = ""
356
+ per_queue: bool = True
357
+ unknown_policy: ErrorSeverity = ErrorSeverity.PERMANENT
358
+ strict_delays: bool = True
359
+
360
+ def __post_init__(self) -> None:
361
+ if self.max_retries < 0:
362
+ raise ValueError(f"RetryConfig.max_retries must be >= 0, got {self.max_retries}")
363
+ if self.jitter_mode not in ("off", "sharded"):
364
+ raise ValueError(
365
+ f"RetryConfig.jitter_mode must be 'off' or 'sharded', got {self.jitter_mode!r}"
366
+ )
367
+ if self.jitter_mode == "sharded":
368
+ if self.jitter_shards < 2:
369
+ raise ValueError(
370
+ f"RetryConfig.jitter_shards must be >= 2 with jitter_mode='sharded', "
371
+ f"got {self.jitter_shards}"
372
+ )
373
+ if not (0 < self.jitter_factor < 1):
374
+ raise ValueError(
375
+ "RetryConfig.jitter_factor must be in (0, 1) with jitter_mode='sharded' "
376
+ f"(it sets the TTL spread), got {self.jitter_factor}"
377
+ )
378
+ if not self.per_queue:
379
+ # H3: shared delay queues (rabbitkit.retry.N) bake a single
380
+ # x-dead-letter-routing-key into each queue at declare time. That
381
+ # key can only point at ONE source queue, so with >1 subscriber
382
+ # they either 406 at startup (conflicting args) or silently
383
+ # dead-letter every queue's failures back to whichever queue
384
+ # declared first — cross-queue misdelivery (orders' failures
385
+ # reappearing on payments). A shared delay queue physically
386
+ # cannot route each message back to its own varying source with
387
+ # static broker config, so there is no safe shared topology.
388
+ raise ValueError(
389
+ "RetryConfig(per_queue=False) is unsafe and unsupported: shared "
390
+ "delay queues misroute failed messages across source queues (or "
391
+ "406 at startup). Use per_queue=True (the default), which gives "
392
+ "each queue isolated '<queue>.retry.N'/'<queue>.dlq' topology."
393
+ )
394
+ if len(self.delays) < self.max_retries:
395
+ msg = (
396
+ f"RetryConfig.delays has {len(self.delays)} entries but max_retries={self.max_retries}. "
397
+ "Retries beyond the last delay entry will reuse the last delay value, "
398
+ "which is almost always a misconfiguration. Provide at least max_retries "
399
+ "delay values, or set strict_delays=False to allow the flat-tail behavior."
400
+ )
401
+ if self.strict_delays:
402
+ raise ValueError(msg)
403
+ import warnings
404
+
405
+ warnings.warn(msg, UserWarning, stacklevel=2)
406
+
407
+
408
+ # NOTE: ``RetryConfig`` no longer imports ``warnings`` at module top-level; the
409
+ # warning is only emitted on the non-strict path. Strict (default) raises so
410
+ # misconfiguration fails fast per the project's fail-fast philosophy.
411
+
412
+
413
+ # ── Compression ────────────────────────────────────────────────────────────
414
+
415
+
416
+ @dataclass(frozen=True, slots=True)
417
+ class CompressionConfig:
418
+ """Compression configuration.
419
+
420
+ Accepted by CompressionMiddleware.
421
+ """
422
+
423
+ algorithm: str = "gzip"
424
+ threshold: int = 1024
425
+ level: int = 6
426
+
427
+
428
+ # ── Metrics ───────────────────────────────────────────────────────────────
429
+
430
+
431
+ @dataclass(frozen=True, slots=True)
432
+ class MetricsConfig:
433
+ """Metrics naming configuration.
434
+
435
+ ``namespace`` sets the metric name prefix (default "rabbitkit").
436
+ Individual name fields override the derived default when non-empty.
437
+ """
438
+
439
+ namespace: str = "rabbitkit"
440
+ consumed_counter: str = ""
441
+ processing_histogram: str = ""
442
+ published_counter: str = ""
443
+ publish_histogram: str = ""
444
+
445
+ @property
446
+ def consumed_total(self) -> str:
447
+ return self.consumed_counter or f"{self.namespace}_messages_consumed_total"
448
+
449
+ @property
450
+ def processing_seconds(self) -> str:
451
+ return self.processing_histogram or f"{self.namespace}_message_processing_seconds"
452
+
453
+ @property
454
+ def messages_acked_total(self) -> str:
455
+ return f"{self.namespace}_messages_acked_total"
456
+
457
+ @property
458
+ def messages_nacked_total(self) -> str:
459
+ return f"{self.namespace}_messages_nacked_total"
460
+
461
+ @property
462
+ def messages_rejected_total(self) -> str:
463
+ return f"{self.namespace}_messages_rejected_total"
464
+
465
+ @property
466
+ def messages_retried_total(self) -> str:
467
+ return f"{self.namespace}_messages_retried_total"
468
+
469
+ @property
470
+ def messages_dead_lettered_total(self) -> str:
471
+ return f"{self.namespace}_messages_dead_lettered_total"
472
+
473
+ @property
474
+ def dedup_fallback_total(self) -> str:
475
+ """M9: incremented every time DeduplicationMiddleware falls back to
476
+ processing a message despite a Redis error (idempotency is not
477
+ enforced for that message) — see
478
+ ``DeduplicationConfig.fallback_on_redis_error``."""
479
+ return f"{self.namespace}_dedup_fallback_total"
480
+
481
+ @property
482
+ def rate_limit_dropped_total(self) -> str:
483
+ """L5: incremented every time RateLimitMiddleware settles a message
484
+ without calling the handler — nack/drop policy, or the "wait" policy's
485
+ deadline elapsing with no token acquired. Labeled by ``reason``
486
+ (``nack``/``drop``/``wait_deadline_exceeded``)."""
487
+ return f"{self.namespace}_rate_limit_dropped_total"
488
+
489
+ @property
490
+ def messages_redelivered_total(self) -> str:
491
+ """Incremented (labeled by ``queue``) for every consumed message the
492
+ broker flagged ``redelivered=True`` — the broker-redelivery-rate
493
+ signal. A sustained rise means handlers are dying/timing out before
494
+ acking (crash loops, heartbeat kills, connection churn), which the
495
+ success/error consume counters alone can't distinguish from ordinary
496
+ traffic."""
497
+ return f"{self.namespace}_messages_redelivered_total"
498
+
499
+ @property
500
+ def reconnects_total(self) -> str:
501
+ """Incremented on every transport re-connection after the first
502
+ successful connect — the connection-churn signal. Reconnects were
503
+ previously logged but never counted, so a flapping broker/network
504
+ was invisible to metrics-based alerting."""
505
+ return f"{self.namespace}_reconnects_total"
506
+
507
+ @property
508
+ def published_total(self) -> str:
509
+ return self.published_counter or f"{self.namespace}_messages_published_total"
510
+
511
+ @property
512
+ def publish_total(self) -> str:
513
+ return self.published_counter or f"{self.namespace}_publish_total"
514
+
515
+ @property
516
+ def publish_failures_total(self) -> str:
517
+ return f"{self.namespace}_publish_failures_total"
518
+
519
+ @property
520
+ def publish_confirm_latency_seconds(self) -> str:
521
+ return f"{self.namespace}_publish_confirm_latency_seconds"
522
+
523
+ @property
524
+ def publish_seconds(self) -> str:
525
+ return self.publish_histogram or f"{self.namespace}_message_publish_seconds"
526
+
527
+ @property
528
+ def in_flight_messages(self) -> str:
529
+ return f"{self.namespace}_in_flight_messages"
530
+
531
+ @property
532
+ def worker_pool_pending(self) -> str:
533
+ return f"{self.namespace}_worker_pool_pending"
534
+
535
+ @property
536
+ def broker_connected(self) -> str:
537
+ return f"{self.namespace}_broker_connected"
538
+
539
+ @property
540
+ def consumer_active(self) -> str:
541
+ return f"{self.namespace}_consumer_active"
542
+
543
+ # ── Broker-side gauges (H5: polled from the management API) ──
544
+ # Bridged by QueueMetricsPoller — the #1 RabbitMQ incident signal
545
+ # (queue growth / consumer lag) that the consume/publish counters cannot
546
+ # see. All labeled by {queue}.
547
+
548
+ @property
549
+ def queue_messages_ready(self) -> str:
550
+ """Messages ready for delivery (backlog depth)."""
551
+ return f"{self.namespace}_queue_messages_ready"
552
+
553
+ @property
554
+ def queue_messages_unacked(self) -> str:
555
+ """Messages delivered but not yet acked (in-flight at consumers)."""
556
+ return f"{self.namespace}_queue_messages_unacked"
557
+
558
+ @property
559
+ def queue_messages_total(self) -> str:
560
+ """Total messages in the queue (ready + unacked)."""
561
+ return f"{self.namespace}_queue_messages_total"
562
+
563
+ @property
564
+ def queue_consumers(self) -> str:
565
+ """Number of consumers attached to the queue (0 = nothing draining)."""
566
+ return f"{self.namespace}_queue_consumers"
567
+
568
+
569
+ # ── Health Check ──────────────────────────────────────────────────────────
570
+
571
+
572
+ @dataclass(frozen=True, slots=True)
573
+ class HealthCheckConfig:
574
+ """Thresholds for broker_health_check()."""
575
+
576
+ pending_threshold: int = 100
577
+
578
+
579
+ # ── Sentinel ──────────────────────────────────────────────────────────────
580
+
581
+
582
+ class RetryDisabled:
583
+ """Typed singleton — explicitly disables retry on a route.
584
+
585
+ Distinct from RetryConfig(max_retries=0) which means 'retry-owned
586
+ terminal semantics with zero retry attempts (immediate DLQ on any
587
+ classified error).'
588
+ """
589
+
590
+ _instance: RetryDisabled | None = None
591
+
592
+ def __new__(cls) -> RetryDisabled:
593
+ if cls._instance is None:
594
+ cls._instance = super().__new__(cls)
595
+ return cls._instance
596
+
597
+ def __repr__(self) -> str:
598
+ return "RETRY_DISABLED"
599
+
600
+ def __bool__(self) -> bool:
601
+ return False
602
+
603
+
604
+ RETRY_DISABLED = RetryDisabled()
605
+
606
+
607
+ # ── Deduplication (active) ──────────────────────────────────
608
+
609
+
610
+ @dataclass(frozen=True, slots=True)
611
+ class DeduplicationConfig:
612
+ """Deduplication configuration. Active.
613
+
614
+ ``mark_policy`` accepts a :class:`~rabbitkit.core.types.DeduplicationMarkPolicy`
615
+ member or its string value:
616
+
617
+ - ``"on_success"`` (default) — crash-safe; concurrent duplicates may both run.
618
+ - ``"on_start"`` — blocks concurrent duplicates but a crash mid-handler
619
+ LOSES the message. Advanced/dangerous.
620
+ - ``"claim"`` — in-flight claim (``processing_timeout``) before the
621
+ handler, "completed" (``ttl``) after success. Blocks concurrent
622
+ duplicates and survives crashes; ``processing_timeout`` must
623
+ comfortably exceed the worst-case handler duration or a duplicate can
624
+ start while the original is still running.
625
+ """
626
+
627
+ key_prefix: str = "rabbitkit:dedup"
628
+ ttl: int = 86400
629
+ fallback_on_redis_error: bool = True
630
+ key_source: str = "message_id"
631
+ mark_policy: str = "on_success"
632
+ local_cache_size: int = 0 # 0 = disabled; >0 = in-process LRU capacity (short-circuits Redis for known duplicates)
633
+ processing_timeout: int = 300 # claim only: in-flight claim TTL (seconds)
634
+ # F5 (idempotent receiver): with mark_policy="claim", store the handler's
635
+ # JSON-serializable result alongside the completed mark; a duplicate
636
+ # delivery then REPLAYS the stored result (the pipeline re-publishes it to
637
+ # the route's result publisher / reply_to, byte-identical) instead of just
638
+ # skipping. Results that aren't JSON-serializable or exceed
639
+ # max_result_bytes degrade gracefully to plain skip-without-replay.
640
+ # This is the idempotent-receiver EFFECT — wire-level exactly-once does
641
+ # not exist on RabbitMQ and this does not claim otherwise.
642
+ store_results: bool = False
643
+ max_result_bytes: int = 65536
644
+ on_in_flight: str = "nack_requeue" # claim only: "nack_requeue" (retry-safe) | "ack_skip"
645
+
646
+ def __post_init__(self) -> None:
647
+ if self.store_results and self.mark_policy != "claim":
648
+ raise ValueError(
649
+ "DeduplicationConfig.store_results=True requires mark_policy='claim' "
650
+ f"(got {self.mark_policy!r}) — result replay is only crash-safe on the "
651
+ "claim state machine."
652
+ )
653
+ if self.max_result_bytes < 1:
654
+ raise ValueError(
655
+ f"DeduplicationConfig.max_result_bytes must be >= 1, got {self.max_result_bytes}"
656
+ )
657
+ if self.mark_policy not in ("on_success", "on_start", "claim"):
658
+ raise ValueError(
659
+ f"DeduplicationConfig.mark_policy must be one of "
660
+ f"'on_success', 'on_start', 'claim'; got {self.mark_policy!r}"
661
+ )
662
+ if self.on_in_flight not in ("nack_requeue", "ack_skip"):
663
+ raise ValueError(
664
+ f"DeduplicationConfig.on_in_flight must be 'nack_requeue' or "
665
+ f"'ack_skip'; got {self.on_in_flight!r}"
666
+ )
667
+ if self.processing_timeout <= 0:
668
+ raise ValueError(
669
+ f"DeduplicationConfig.processing_timeout must be > 0, got {self.processing_timeout}"
670
+ )
671
+
672
+
673
+ # ── Safety (active) ─────────────────────────────────────────
674
+
675
+
676
+ @dataclass(frozen=True, slots=True)
677
+ class SafetyConfig:
678
+ """Message-safety policies. Active.
679
+
680
+ ``reject_without_dlx`` — what to do when a route can
681
+ ``reject(requeue=False)`` but its queue has no dead-letter exchange
682
+ (RabbitMQ silently discards such rejects). Accepts a
683
+ :class:`~rabbitkit.core.types.RejectWithoutDLXPolicy` member or its
684
+ string value:
685
+
686
+ - ``"auto_provision"`` (default): declare ``{queue}{dlq_suffix}`` and
687
+ wire the source queue's DLX to it — poison messages are preserved.
688
+ - ``"error"``: fail startup with ``UnsafeTopologyError``. For teams that
689
+ manage topology externally.
690
+ - ``"discard"``: explicitly allow RabbitMQ to discard rejected messages
691
+ (warns once per route unless ``warn_on_discard=False``).
692
+
693
+ Only applied under ``TopologyMode.AUTO_DECLARE``; per-route override via
694
+ ``@subscriber(reject_without_dlx=...)``.
695
+ """
696
+
697
+ reject_without_dlx: str = "auto_provision"
698
+ dlq_suffix: str = ".dlq"
699
+ warn_on_discard: bool = True
700
+ # M14: what to do when declaring a queue/exchange 406s because it already
701
+ # exists with incompatible arguments (drift — e.g. ops created it, or a
702
+ # prior rabbitkit version, with a different type/TTL/DLX).
703
+ # - "raise" (default): fail startup with a typed ConfigurationError. Safe
704
+ # default — surfaces the drift instead of silently ignoring your config.
705
+ # - "warn_continue": log a warning and CONTINUE using the EXISTING
706
+ # definition (rabbitkit's declaration is NOT applied). Unlike
707
+ # TopologyMode.PASSIVE_ONLY (which skips declaration for EVERY queue),
708
+ # this still actively declares non-conflicting queues and only tolerates
709
+ # the ones that drifted — the per-conflict warn-and-continue mode.
710
+ on_topology_conflict: str = "raise"
711
+
712
+ def __post_init__(self) -> None:
713
+ if self.reject_without_dlx not in ("auto_provision", "error", "discard"):
714
+ raise ValueError(
715
+ f"SafetyConfig.reject_without_dlx must be one of "
716
+ f"'auto_provision', 'error', 'discard'; got {self.reject_without_dlx!r}"
717
+ )
718
+ if not self.dlq_suffix:
719
+ raise ValueError("SafetyConfig.dlq_suffix must be non-empty")
720
+ if self.on_topology_conflict not in ("raise", "warn_continue"):
721
+ raise ValueError(
722
+ f"SafetyConfig.on_topology_conflict must be 'raise' or 'warn_continue'; "
723
+ f"got {self.on_topology_conflict!r}"
724
+ )
725
+
726
+
727
+ # ── Backpressure (active) ───────────────────────────────────
728
+
729
+
730
+ @dataclass(frozen=True, slots=True)
731
+ class BackpressureConfig:
732
+ """Backpressure configuration. Active."""
733
+
734
+ max_in_flight: int = 1000
735
+ rate_limit: int | None = None
736
+ blocked_timeout: float = 60.0
737
+ on_blocked: str = "wait"
738
+ poll_interval_ms: int = 10
739
+
740
+
741
+ # ── Batch (active) ──────────────────────────────────────────
742
+
743
+
744
+ @dataclass(frozen=True, slots=True)
745
+ class BatchPublishConfig:
746
+ """Batch publish configuration. Active."""
747
+
748
+ batch_size: int = 100
749
+ flush_interval_ms: int = 50
750
+ max_in_flight: int = 1000
751
+ flush_workers: int = 0 # 0 = auto (min(16, max_in_flight // batch_size)); >0 = explicit count
752
+
753
+ def __post_init__(self) -> None:
754
+ if self.batch_size <= 0:
755
+ raise ValueError(f"BatchPublishConfig.batch_size must be > 0, got {self.batch_size}")
756
+ if self.flush_interval_ms < 0:
757
+ raise ValueError(
758
+ f"BatchPublishConfig.flush_interval_ms must be >= 0, got {self.flush_interval_ms}"
759
+ )
760
+ if self.max_in_flight <= 0:
761
+ raise ValueError(
762
+ f"BatchPublishConfig.max_in_flight must be > 0, got {self.max_in_flight}"
763
+ )
764
+ if self.flush_workers < 0:
765
+ raise ValueError(
766
+ f"BatchPublishConfig.flush_workers must be >= 0, got {self.flush_workers}"
767
+ )
768
+
769
+
770
+ @dataclass(frozen=True, slots=True)
771
+ class BatchAckConfig:
772
+ """Batch ack configuration. Active."""
773
+
774
+ batch_size: int = 100
775
+ flush_interval_ms: int = 200
776
+
777
+
778
+ # ── Worker (active) ─────────────────────────────────────────
779
+
780
+
781
+ @dataclass(frozen=True, slots=True)
782
+ class WorkerConfig:
783
+ """Consumer concurrency configuration.
784
+
785
+ Accepted by broker.start(), NOT part of RabbitConfig. Added in 0.2.0.
786
+
787
+ ``stop_timeout`` (H12): the drain deadline given to a multi-worker pool's
788
+ ``stop()`` — it must exceed your slowest handler's expected run time, and
789
+ should be a few seconds *less* than ``terminationGracePeriodSeconds`` (k8s)
790
+ so the graceful drain always has a chance to finish before SIGKILL. A
791
+ handler still running past this deadline is **abandoned, not killed**:
792
+ the sync pool's daemon thread keeps running in the background (it is
793
+ never forcibly stopped — Python cannot interrupt an arbitrary thread),
794
+ and the async pool cancels the task (which does not guarantee the
795
+ handler reaches its own ack/nack — ``CancelledError`` is a
796
+ ``BaseException`` and is not caught by the pipeline's exception
797
+ handling). Either way the abandoned delivery is logged by delivery
798
+ tag/message id, and — for the async pool — nacked for redelivery
799
+ immediately rather than relying on the implicit requeue that happens
800
+ when the connection eventually closes. Because the original handler may
801
+ still complete its side effects after abandonment, **handlers must be
802
+ idempotent under at-least-once delivery** regardless of ``stop_timeout``.
803
+ """
804
+
805
+ worker_count: int = 1
806
+ prefetch_per_worker: int | None = None
807
+ stop_timeout: float = 30.0
808
+ # M11: bound the sync worker pool's internal work queue. 0 = unbounded
809
+ # (default, unchanged). In practice the broker's prefetch already caps
810
+ # in-flight messages (effective prefetch = worker_count x
811
+ # prefetch_per_worker), so this is a defensive ceiling — set it >= your
812
+ # effective prefetch so it only trips if prefetch isn't being honored.
813
+ # When the queue is full, submit() blocks (backpressure); keep it above
814
+ # prefetch so that never happens on the I/O thread.
815
+ max_queue_size: int = 0
816
+
817
+ def __post_init__(self) -> None:
818
+ if self.worker_count < 1:
819
+ raise ValueError(f"WorkerConfig.worker_count must be >= 1, got {self.worker_count}")
820
+ if self.max_queue_size < 0:
821
+ raise ValueError(f"WorkerConfig.max_queue_size must be >= 0, got {self.max_queue_size}")
822
+
823
+
824
+ # ── Top-Level Config ─────────────────────────────────────────────────────
825
+
826
+
827
+ @dataclass(frozen=True, slots=True)
828
+ class RabbitConfig:
829
+ """Top-level config — composes focused config objects.
830
+
831
+ Frozen + slots (per project convention). Brokers that need to apply
832
+ per-route overrides (e.g. prefetch) hold a private ``dataclasses.replace``
833
+ copy rather than mutating the caller's object.
834
+
835
+ Only connection/broker defaults. Throughput/batching configs
836
+ are accepted by their respective components directly.
837
+ """
838
+
839
+ connection: ConnectionConfig = field(default_factory=ConnectionConfig)
840
+ socket: SocketConfig = field(default_factory=SocketConfig)
841
+ security: SecurityConfig = field(default_factory=SecurityConfig)
842
+ publisher: PublisherConfig = field(default_factory=PublisherConfig)
843
+ consumer: ConsumerConfig = field(default_factory=ConsumerConfig)
844
+ pool: PoolConfig = field(default_factory=PoolConfig)
845
+ topology_mode: TopologyMode = TopologyMode.AUTO_DECLARE
846
+ safety: SafetyConfig = field(default_factory=SafetyConfig)
847
+ retry: RetryConfig | None = None
848
+ compression: CompressionConfig | None = None
849
+ logging: LoggingConfig | None = None