rabbitkit 0.17.0__tar.gz → 0.19.0__tar.gz

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 (111) hide show
  1. {rabbitkit-0.17.0/src/rabbitkit.egg-info → rabbitkit-0.19.0}/PKG-INFO +2 -2
  2. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/pyproject.toml +2 -2
  3. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/__init__.py +2 -0
  4. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/_version.py +1 -1
  5. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/broker.py +59 -13
  6. rabbitkit-0.19.0/src/rabbitkit/async_/connection.py +419 -0
  7. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/transport.py +222 -75
  8. rabbitkit-0.19.0/src/rabbitkit/cli/commands/dlq.py +403 -0
  9. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/config.py +18 -0
  10. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/errors.py +11 -0
  11. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/message.py +5 -0
  12. rabbitkit-0.19.0/src/rabbitkit/core/quorum.py +159 -0
  13. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/types.py +27 -1
  14. rabbitkit-0.19.0/src/rabbitkit/dlq.py +695 -0
  15. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/deduplication.py +10 -1
  16. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/retry.py +30 -6
  17. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/broker.py +59 -13
  18. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/transport.py +82 -3
  19. {rabbitkit-0.17.0 → rabbitkit-0.19.0/src/rabbitkit.egg-info}/PKG-INFO +2 -2
  20. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/SOURCES.txt +1 -0
  21. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/requires.txt +1 -1
  22. rabbitkit-0.17.0/src/rabbitkit/async_/connection.py +0 -287
  23. rabbitkit-0.17.0/src/rabbitkit/cli/commands/dlq.py +0 -190
  24. rabbitkit-0.17.0/src/rabbitkit/dlq.py +0 -336
  25. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/LICENSE +0 -0
  26. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/README.md +0 -0
  27. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/setup.cfg +0 -0
  28. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/aio/__init__.py +0 -0
  29. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/__init__.py +0 -0
  30. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/batch.py +0 -0
  31. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/pool.py +0 -0
  32. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/asyncapi/__init__.py +0 -0
  33. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/asyncapi/generator.py +0 -0
  34. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/asyncapi/schema.py +0 -0
  35. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/__init__.py +0 -0
  36. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/_utils.py +0 -0
  37. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/__init__.py +0 -0
  38. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/health.py +0 -0
  39. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/migrate.py +0 -0
  40. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/routes.py +0 -0
  41. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/run.py +0 -0
  42. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/shell.py +0 -0
  43. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/topology.py +0 -0
  44. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/concurrency.py +0 -0
  45. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/__init__.py +0 -0
  46. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/app.py +0 -0
  47. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/bulk.py +0 -0
  48. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/bulk_runner.py +0 -0
  49. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/env_config.py +0 -0
  50. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/logging.py +0 -0
  51. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/path.py +0 -0
  52. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/pipeline.py +0 -0
  53. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/profiles.py +0 -0
  54. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/protocols.py +0 -0
  55. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/registry.py +0 -0
  56. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/retry_handoff.py +0 -0
  57. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/route.py +0 -0
  58. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/router.py +0 -0
  59. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/sanitizer.py +0 -0
  60. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/settlement.py +0 -0
  61. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/topology.py +0 -0
  62. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/topology_dispatch.py +0 -0
  63. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/dashboard/__init__.py +0 -0
  64. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/dashboard/app.py +0 -0
  65. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/di/__init__.py +0 -0
  66. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/di/context.py +0 -0
  67. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/di/depends.py +0 -0
  68. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/di/resolver.py +0 -0
  69. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/experimental/__init__.py +0 -0
  70. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/fastapi.py +0 -0
  71. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/health.py +0 -0
  72. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/highload/__init__.py +0 -0
  73. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/highload/async_acker.py +0 -0
  74. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/highload/backpressure.py +0 -0
  75. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/highload/batch.py +0 -0
  76. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/locking.py +0 -0
  77. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/management.py +0 -0
  78. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/__init__.py +0 -0
  79. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/base.py +0 -0
  80. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/circuit_breaker.py +0 -0
  81. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/compression.py +0 -0
  82. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/error_classifier.py +0 -0
  83. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/exception.py +0 -0
  84. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/metrics.py +0 -0
  85. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/otel.py +0 -0
  86. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/rate_limit.py +0 -0
  87. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/signing.py +0 -0
  88. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/timeout.py +0 -0
  89. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/py.typed +0 -0
  90. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/queue_metrics.py +0 -0
  91. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/results/__init__.py +0 -0
  92. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/results/backend.py +0 -0
  93. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/results/middleware.py +0 -0
  94. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/rpc.py +0 -0
  95. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/__init__.py +0 -0
  96. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/base.py +0 -0
  97. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/json.py +0 -0
  98. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/msgspec.py +0 -0
  99. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/pipeline.py +0 -0
  100. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/streams.py +0 -0
  101. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/__init__.py +0 -0
  102. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/batch.py +0 -0
  103. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/connection.py +0 -0
  104. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/pool.py +0 -0
  105. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/testing/__init__.py +0 -0
  106. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/testing/app.py +0 -0
  107. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/testing/broker.py +0 -0
  108. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/testing/fixtures.py +0 -0
  109. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/dependency_links.txt +0 -0
  110. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/entry_points.txt +0 -0
  111. {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rabbitkit
3
- Version: 0.17.0
3
+ Version: 0.19.0
4
4
  Summary: Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability
5
5
  Author-email: Talaat Magdy <talaatmagdy75@gmail.com>
6
6
  License-Expression: MIT
@@ -27,7 +27,7 @@ Requires-Dist: structlog<27.0.0,>=23.1.0
27
27
  Provides-Extra: sync
28
28
  Requires-Dist: pika<2.0.0,>=1.3.0; extra == "sync"
29
29
  Provides-Extra: async
30
- Requires-Dist: aio-pika<10.0.0,>=9.1.0; extra == "async"
30
+ Requires-Dist: aio-pika<11.0.0,>=9.1.0; extra == "async"
31
31
  Provides-Extra: all-brokers
32
32
  Requires-Dist: rabbitkit[async,sync]; extra == "all-brokers"
33
33
  Provides-Extra: redis
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rabbitkit"
3
- version = "0.17.0"
3
+ version = "0.19.0"
4
4
  description = "Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -31,7 +31,7 @@ dependencies = [
31
31
 
32
32
  [project.optional-dependencies]
33
33
  sync = ["pika>=1.3.0,<2.0.0"]
34
- async = ["aio-pika>=9.1.0,<10.0.0"]
34
+ async = ["aio-pika>=9.1.0,<11.0.0"]
35
35
  all-brokers = ["rabbitkit[sync,async]"]
36
36
  redis = ["redis>=5.0.0,<9.0.0"]
37
37
  # Floor is the first release with no open advisory (verified against OSV):
@@ -46,6 +46,7 @@ from rabbitkit.core.errors import (
46
46
  MissingDependencyError,
47
47
  SettlementError,
48
48
  TopologyValidationError,
49
+ UnsafeToBrowseError,
49
50
  UnsafeTopologyError,
50
51
  )
51
52
  from rabbitkit.core.logging import DEFAULT_REDACT_KEYS, LoggingConfig, configure_structlog
@@ -288,6 +289,7 @@ __all__ = [
288
289
  "TopologyMode",
289
290
  "TopologyValidationError",
290
291
  "UnknownDeliveryError",
292
+ "UnsafeToBrowseError",
291
293
  "UnsafeTopologyError",
292
294
  "WorkerConfig",
293
295
  "__version__",
@@ -1,3 +1,3 @@
1
1
  """rabbitkit version — single source of truth."""
2
2
 
3
- __version__ = "0.17.0"
3
+ __version__ = "0.19.0"
@@ -42,6 +42,7 @@ from rabbitkit.core.path import extract_path, to_binding_key
42
42
  from rabbitkit.core.pipeline import HandlerPipeline
43
43
  from rabbitkit.core.profiles import PreflightReport
44
44
  from rabbitkit.core.profiles import preflight as run_preflight
45
+ from rabbitkit.core.quorum import dlq_delivery_limit
45
46
  from rabbitkit.core.registry import SubscriberRegistry
46
47
  from rabbitkit.core.route import RouteDefinition
47
48
  from rabbitkit.core.settlement import SettlementItem, SettlementReport, settle_many_async
@@ -57,7 +58,7 @@ from rabbitkit.core.types import (
57
58
  TopologyMode,
58
59
  )
59
60
  from rabbitkit.middleware.base import BaseMiddleware
60
- from rabbitkit.middleware.retry import RetryRouter
61
+ from rabbitkit.middleware.retry import RetryRouter, dlq_queue_definition
61
62
  from rabbitkit.serialization.base import Serializer
62
63
 
63
64
  if TYPE_CHECKING:
@@ -1318,7 +1319,22 @@ class AsyncBroker:
1318
1319
  source_queue = route.queue
1319
1320
 
1320
1321
  # Declare queue (with DLQ routing arguments if retry/safety DLX applies)
1321
- await self._transport.declare_queue(source_queue)
1322
+ if source_queue is route.queue and is_anothers_dlq and route.queue.queue_type == QueueType.QUORUM:
1323
+ # Consuming another route's quorum DLQ: declare it exactly as
1324
+ # that route's retry/safety topology does, or the two
1325
+ # declarations disagree on x-delivery-limit (406).
1326
+ await self._declare_dlq(
1327
+ replace(
1328
+ route.queue,
1329
+ delivery_limit=(
1330
+ route.queue.delivery_limit
1331
+ if route.queue.delivery_limit is not None
1332
+ else dlq_delivery_limit(self._broker_server_version())
1333
+ ),
1334
+ )
1335
+ )
1336
+ else:
1337
+ await self._transport.declare_queue(source_queue)
1322
1338
 
1323
1339
  # Bind queue to exchange (C4: bind_arguments matter for headers exchanges)
1324
1340
  if route.exchange is not None:
@@ -1333,23 +1349,53 @@ class AsyncBroker:
1333
1349
  if retry_config is not None:
1334
1350
  exchange_name = route.exchange.name if route.exchange else ""
1335
1351
  delay_queues = retry_router.get_delay_queue_definitions(
1336
- route.queue.name, exchange_name, source_queue_type=route.queue.queue_type
1352
+ route.queue.name,
1353
+ exchange_name,
1354
+ source_queue_type=route.queue.queue_type,
1355
+ broker_version=self._broker_server_version(),
1337
1356
  )
1357
+ dlq_name = retry_router.get_dlq_name(route.queue.name)
1338
1358
  for delay_queue in delay_queues:
1339
- await self._transport.declare_queue(delay_queue)
1359
+ if delay_queue.name == dlq_name:
1360
+ await self._declare_dlq(delay_queue)
1361
+ else:
1362
+ await self._transport.declare_queue(delay_queue)
1340
1363
  elif safety_dlq_name is not None:
1341
- await self._transport.declare_queue(
1342
- RabbitQueue(
1343
- name=safety_dlq_name,
1344
- durable=True,
1345
- # Inherit quorum from a quorum source (see RetryRouter
1346
- # DLQ note): the DLQ stores failures indefinitely.
1347
- queue_type=(
1348
- QueueType.QUORUM if route.queue.queue_type == QueueType.QUORUM else QueueType.CLASSIC
1349
- ),
1364
+ # Inherit quorum from a quorum source (see RetryRouter DLQ
1365
+ # note): the DLQ stores failures indefinitely.
1366
+ await self._declare_dlq(
1367
+ dlq_queue_definition(
1368
+ safety_dlq_name,
1369
+ quorum=route.queue.queue_type == QueueType.QUORUM,
1370
+ broker_version=self._broker_server_version(),
1350
1371
  )
1351
1372
  )
1352
1373
 
1374
+ def _broker_server_version(self) -> str | None:
1375
+ version = getattr(self._transport, "server_version", None)
1376
+ return version if isinstance(version, str) else None
1377
+
1378
+ async def _declare_dlq(self, dlq: RabbitQueue) -> None:
1379
+ """Declare a DLQ, leaving an existing one exactly as it is.
1380
+
1381
+ A quorum DLQ on RabbitMQ 4.x carries ``x-delivery-limit: -1`` (see
1382
+ ``dlq_queue_definition``). RabbitMQ refuses to add that argument to
1383
+ a queue declared without it, and refuses to drop it from one declared
1384
+ with it (406 either way). So a DLQ that already exists is not
1385
+ redeclared at all; its delivery limit is the operator's to fix, with
1386
+ a policy, and ``DLQInspector`` refuses to browse it until they do.
1387
+ """
1388
+ if dlq.queue_type is QueueType.QUORUM and self._config.topology_mode is TopologyMode.AUTO_DECLARE:
1389
+ # Probe whatever the version: an existing DLQ may carry the
1390
+ # argument (created by 0.19 on 4.x) even when this connection
1391
+ # can't tell which broker version it is talking to.
1392
+ assert self._transport is not None
1393
+ if await self._transport.queue_exists(dlq.name) is True:
1394
+ logger.debug("DLQ %r already exists; not redeclaring it", dlq.name)
1395
+ return
1396
+ assert self._transport is not None
1397
+ await self._transport.declare_queue(dlq)
1398
+
1353
1399
  async def _start_consumer(self, route: RouteDefinition) -> None:
1354
1400
  """Start consuming for a single route."""
1355
1401
  if self._transport is None:
@@ -0,0 +1,419 @@
1
+ """aio-pika-specific connection parameter builders and error tuples.
2
+
3
+ This is where all aio-pika imports live — core/ stays clean.
4
+ Provides helpers to build aio_pika.connect_robust() kwargs from rabbitkit config objects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import logging
12
+ import random
13
+ import ssl
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+ from urllib.parse import quote
17
+
18
+ from rabbitkit._version import __version__
19
+ from rabbitkit.core.config import ConnectionConfig, SecurityConfig, SSLConfig
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ # ── Transport-specific connection errors ──────────────────────────────────
25
+
26
+
27
+ def get_connection_errors() -> tuple[type[BaseException], ...]:
28
+ """Get aio-pika-specific connection error tuple.
29
+
30
+ Returns generic stdlib errors if aio-pika is not installed.
31
+ """
32
+ base_errors: tuple[type[BaseException], ...] = (
33
+ ConnectionResetError,
34
+ BrokenPipeError,
35
+ ConnectionAbortedError,
36
+ ConnectionRefusedError,
37
+ TimeoutError,
38
+ EOFError,
39
+ OSError,
40
+ )
41
+
42
+ try:
43
+ import aio_pika.exceptions
44
+
45
+ aio_pika_errors: tuple[type[BaseException], ...] = (
46
+ aio_pika.exceptions.AMQPConnectionError,
47
+ aio_pika.exceptions.ChannelClosed,
48
+ aio_pika.exceptions.ConnectionClosed,
49
+ )
50
+ return aio_pika_errors + base_errors
51
+ except (ImportError, AttributeError):
52
+ return base_errors
53
+
54
+
55
+ def build_ssl_context(ssl_config: SSLConfig) -> ssl.SSLContext | None:
56
+ """Build stdlib ssl.SSLContext from SSLConfig.
57
+
58
+ Returns None if SSL is not enabled.
59
+ Shared with sync/connection.py — same logic.
60
+ """
61
+ if not ssl_config.enabled:
62
+ return None
63
+
64
+ cert_reqs_map = {
65
+ "CERT_REQUIRED": ssl.CERT_REQUIRED,
66
+ "CERT_OPTIONAL": ssl.CERT_OPTIONAL,
67
+ "CERT_NONE": ssl.CERT_NONE,
68
+ }
69
+ cert_reqs = cert_reqs_map.get(ssl_config.cert_reqs, ssl.CERT_REQUIRED)
70
+
71
+ # M13: disabling certificate verification makes the connection MITM-able —
72
+ # warn loudly (see sync/connection.py for rationale).
73
+ if cert_reqs == ssl.CERT_NONE:
74
+ import warnings
75
+
76
+ warnings.warn(
77
+ "SSLConfig(cert_reqs='CERT_NONE') disables TLS certificate and hostname "
78
+ "verification — the connection is encrypted but MITM-able. Use "
79
+ "'CERT_REQUIRED' (the default) with a proper ca_certs bundle in production.",
80
+ RuntimeWarning,
81
+ stacklevel=2,
82
+ )
83
+
84
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
85
+
86
+ # Defense in depth: never negotiate below TLS 1.2.
87
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
88
+
89
+ # check_hostname must be disabled BEFORE setting verify_mode=CERT_NONE
90
+ if cert_reqs == ssl.CERT_NONE:
91
+ ctx.check_hostname = False
92
+
93
+ ctx.verify_mode = cert_reqs
94
+
95
+ if ssl_config.ca_certs:
96
+ ctx.load_verify_locations(ssl_config.ca_certs)
97
+ elif cert_reqs == ssl.CERT_REQUIRED:
98
+ # No explicit CA bundle configured — fall back to the system trust
99
+ # store so verification actually succeeds against broker certs
100
+ # signed by a well-known CA. Guarded so the explicit-ca_certs path
101
+ # above is unchanged.
102
+ try:
103
+ ctx.load_default_certs()
104
+ except Exception: # pragma: no cover — best effort, platform-dependent
105
+ try:
106
+ ctx.set_default_verify_paths()
107
+ except Exception: # pragma: no cover
108
+ pass
109
+
110
+ if ssl_config.certfile:
111
+ ctx.load_cert_chain(
112
+ certfile=ssl_config.certfile,
113
+ keyfile=ssl_config.keyfile,
114
+ )
115
+
116
+ return ctx
117
+
118
+
119
+ def make_aio_pika_connect_kwargs(
120
+ connection: ConnectionConfig,
121
+ security: SecurityConfig,
122
+ *,
123
+ host_override: str | None = None,
124
+ port_override: int | None = None,
125
+ ) -> dict[str, Any]:
126
+ """Build kwargs for aio_pika.connect_robust().
127
+
128
+ Returns a dict of keyword arguments.
129
+ Raises ImportError if aio-pika is not installed.
130
+
131
+ Note: aio-pika has no native ``blocked_connection_timeout`` knob (unlike
132
+ pika's ``ConnectionParameters.blocked_connection_timeout``). To honour
133
+ ``ConnectionConfig.blocked_connection_timeout`` on the async side, start a
134
+ :class:`BlockedConnectionMonitor` on the returned connection after
135
+ ``connect_robust`` succeeds. It forces a reconnect when a
136
+ ``connection.blocked`` alarm is not cleared within the configured timeout,
137
+ instead of stalling publishes indefinitely.
138
+ """
139
+ try:
140
+ import aio_pika # noqa: F401
141
+ except ImportError:
142
+ raise ImportError(
143
+ "aio-pika is required for async transport. Install it with: pip install rabbitkit[async]"
144
+ ) from None
145
+
146
+ # Build URL — carry heartbeat as a query param. aio-pika/aiormq read heartbeat
147
+ # from the URL; passing it as a kwarg is not portable across versions. Without
148
+ # this the configured ConnectionConfig.heartbeat was silently dropped on async
149
+ # (the sync transport already honors it).
150
+ #
151
+ # URL-encode username/password so credentials containing special characters
152
+ # (e.g. ":", "@", "/", "+") don't corrupt the AMQP URL or leak as plaintext
153
+ # delimiters. ConnectionConfig.url (core/config.py) is NOT in this module's
154
+ # scope, so we rebuild a safe URL here.
155
+ # M13: resolve credentials via credentials_provider if set (rotation).
156
+ raw_user, raw_pwd = connection.resolve_credentials()
157
+ user = quote(raw_user, safe="")
158
+ pwd = quote(raw_pwd, safe="")
159
+ # Every vhost is percent-encoded, not just "/": a vhost such as
160
+ # "orders/eu" or "a#b" inserted raw splits the path or starts a fragment,
161
+ # so the client connects to the wrong vhost or fails to parse the URL.
162
+ # Matches ConnectionConfig.url.
163
+ vhost = quote(connection.vhost, safe="")
164
+ # M9: allow the caller (pool) to target a specific cluster node.
165
+ host = host_override if host_override is not None else connection.host
166
+ port = port_override if port_override is not None else connection.port
167
+ base_url = f"amqp://{user}:{pwd}@{host}:{port}/{vhost}"
168
+ sep = "&" if "?" in base_url else "?"
169
+ url = f"{base_url}{sep}heartbeat={connection.heartbeat}"
170
+
171
+ # H4: aio-pika's connect_robust uses a FIXED reconnect_interval — no
172
+ # exponential backoff. A fleet of consumers restarted together (e.g. a
173
+ # broker bounce under 200 pods) would otherwise retry in lockstep every
174
+ # `reconnect_backoff_base` seconds, hammering the recovering node in a
175
+ # thundering herd. We can't inject exponential backoff into connect_robust,
176
+ # but randomizing the interval PER PROCESS (full jitter over
177
+ # [base, min(base*2, backoff_max)]) de-synchronizes the herd so retries
178
+ # spread across the window instead of arriving as a spike. Each process
179
+ # picks its interval once at connect time; `random` needs no seeding for
180
+ # inter-process spread since PYTHONHASHSEED/os entropy differ per process.
181
+ base = connection.reconnect_backoff_base
182
+ jitter_span = max(0.0, min(base, connection.reconnect_backoff_max - base))
183
+ jittered_interval = base + random.uniform(0.0, jitter_span) # noqa: S311 — jitter, not crypto
184
+ kwargs: dict[str, Any] = {
185
+ "url": url,
186
+ "timeout": connection.socket_timeout,
187
+ "reconnect_interval": jittered_interval,
188
+ }
189
+
190
+ # SSL
191
+ ssl_context = build_ssl_context(security.ssl)
192
+ if ssl_context is not None:
193
+ kwargs["ssl_context"] = ssl_context
194
+
195
+ # Client properties (item 8): rabbitkit always identifies itself;
196
+ # connection_name and any caller-supplied escape-hatch properties
197
+ # (ConnectionConfig.client_properties) are additive on top. aiormq merges
198
+ # this dict INTO its own base properties (product/platform/capabilities/
199
+ # version) at connect time rather than replacing them wholesale — see
200
+ # aiormq.connection.Connection._client_properties — so these never
201
+ # clobber aiormq's own identification or capability flags. Key names
202
+ # avoid "product"/"version"/"platform"/"capabilities"/"information" for
203
+ # that reason.
204
+ client_properties: dict[str, str] = {
205
+ "library": "rabbitkit",
206
+ "library_version": __version__,
207
+ }
208
+ client_properties.update(connection.client_properties)
209
+ if connection.connection_name:
210
+ client_properties["connection_name"] = connection.connection_name
211
+ kwargs["client_properties"] = client_properties
212
+
213
+ return kwargs
214
+
215
+
216
+ def has_small_negative_int(arguments: dict[str, Any] | None) -> bool:
217
+ """True if a field table holds an int pamqp < 4 can't encode (-128..-1)."""
218
+ return any(
219
+ isinstance(v, int) and not isinstance(v, bool) and -128 <= v < 0 for v in (arguments or {}).values()
220
+ )
221
+
222
+
223
+ def ensure_pamqp_encodes_negative_ints() -> None:
224
+ """Fix pamqp 3.x's encoding of field-table integers from -128 to -1.
225
+
226
+ pamqp 3 (what aio-pika 9 depends on) tags them as the signed short-short
227
+ type ``b`` but packs them unsigned, so encoding raises
228
+ ``struct.error: 'B' format requires 0 <= number <= 255``. That made a
229
+ quorum queue with ``x-delivery-limit: -1`` impossible to declare on the
230
+ async transport. pamqp 4 packs them signed. This replaces
231
+ ``pamqp.encode.table_integer`` with one that does the same for the
232
+ broken range and defers to the original for everything else; it only
233
+ changes values that raised before. A no-op where pamqp is already fixed.
234
+ """
235
+ import struct
236
+
237
+ from pamqp import encode
238
+
239
+ if getattr(encode.table_integer, "_rabbitkit_signed_fix", False):
240
+ return
241
+ try:
242
+ encode.table_integer(-1)
243
+ return # pamqp >= 4
244
+ except struct.error:
245
+ pass
246
+
247
+ original = encode.table_integer
248
+ signed = struct.Struct(">b")
249
+
250
+ def table_integer(value: int) -> bytes:
251
+ if -128 <= value < 0:
252
+ return b"b" + signed.pack(value)
253
+ return original(value)
254
+
255
+ table_integer._rabbitkit_signed_fix = True # type: ignore[attr-defined]
256
+ encode.table_integer = table_integer
257
+
258
+
259
+ # Poll period for BlockedConnectionMonitor. Reading an asyncio.Event is a few
260
+ # attribute lookups, so a quarter second costs nothing and bounds how late
261
+ # FlowController hears about an alarm.
262
+ BLOCKED_POLL_INTERVAL = 0.25
263
+
264
+
265
+ def aiormq_unblocked_event(connection: Any) -> asyncio.Event | None:
266
+ """Return aiormq's internal "not blocked" event for an aio-pika connection.
267
+
268
+ No aio-pika release surfaces ``connection.blocked``: 9.x and 10.x have no
269
+ ``connection_blocked`` / ``connection_unblocked`` callback collections.
270
+ aiormq (6.x and 7.x) handles the frames itself. It clears a private
271
+ ``Connection.__connection_unblocked`` event on ``Connection.Blocked`` and
272
+ sets it on ``Connection.Unblocked``, so that event is the only place the
273
+ state exists. ``None`` when the connection has no live transport
274
+ (mid-reconnect) or aiormq no longer has the attribute.
275
+ """
276
+ underlay = getattr(getattr(connection, "transport", None), "connection", None)
277
+ event = getattr(underlay, "_Connection__connection_unblocked", None)
278
+ return event if isinstance(event, asyncio.Event) else None
279
+
280
+
281
+ class BlockedConnectionMonitor:
282
+ """Track RabbitMQ's ``connection.blocked`` state for one aio-pika connection.
283
+
284
+ Polls :func:`aiormq_unblocked_event` and, on each transition, calls
285
+ *on_blocked* / *on_unblocked* (the transport's ``is_blocked`` flag and any
286
+ ``FlowController``). With ``blocked_timeout > 0`` it also forces a
287
+ reconnect when an alarm outlasts the timeout. It does that by closing the
288
+ UNDERLYING aiormq connection, so ``RobustConnection`` reconnects. Calling
289
+ ``RobustConnection.close()`` would shut it down for good.
290
+
291
+ *may_force_reconnect* limits the forced reconnect to some connections:
292
+ the transport allows it only on its publisher connection, because closing
293
+ the consumer connection requeues every in-flight delivery.
294
+
295
+ The task ends by itself once the connection is closed for good. Call
296
+ :meth:`stop` on shutdown.
297
+ """
298
+
299
+ def __init__(
300
+ self,
301
+ connection: Any,
302
+ *,
303
+ blocked_timeout: float,
304
+ on_blocked: Callable[[], None] | None = None,
305
+ on_unblocked: Callable[[], None] | None = None,
306
+ poll_interval: float = BLOCKED_POLL_INTERVAL,
307
+ may_force_reconnect: Callable[[], bool] | None = None,
308
+ ) -> None:
309
+ self._connection = connection
310
+ self._blocked_timeout = blocked_timeout
311
+ self._on_blocked = on_blocked
312
+ self._on_unblocked = on_unblocked
313
+ self._poll_interval = poll_interval
314
+ self._may_force_reconnect = may_force_reconnect
315
+ self._blocked = False
316
+ self._blocked_since: float | None = None
317
+ self._underlay: Any = None
318
+ self._task: asyncio.Task[None] | None = None
319
+ self.forced_reconnects = 0
320
+
321
+ @property
322
+ def is_blocked(self) -> bool:
323
+ return self._blocked
324
+
325
+ @staticmethod
326
+ def supported(connection: Any) -> bool:
327
+ """False when the connection is live but aiormq has no unblocked event.
328
+
329
+ That means an aiormq release this code doesn't know. The monitor
330
+ can't work there, and that should be visible, not a silent no-op.
331
+ """
332
+ underlay = getattr(getattr(connection, "transport", None), "connection", None)
333
+ return underlay is None or aiormq_unblocked_event(connection) is not None
334
+
335
+ def start(self) -> bool:
336
+ """Start polling. Returns False (and logs a warning) if unsupported."""
337
+ if not self.supported(self._connection):
338
+ logger.warning(
339
+ "aiormq exposes no connection-blocked state on this version; "
340
+ "is_blocked, FlowController and blocked_connection_timeout are "
341
+ "inactive on the async transport"
342
+ )
343
+ return False
344
+ if self._task is None or self._task.done():
345
+ self._task = asyncio.get_running_loop().create_task(self._run())
346
+ return True
347
+
348
+ async def stop(self) -> None:
349
+ task, self._task = self._task, None
350
+ if task is not None and not task.done():
351
+ task.cancel()
352
+ with contextlib.suppress(asyncio.CancelledError, Exception):
353
+ await task
354
+
355
+ async def _run(self) -> None:
356
+ loop = asyncio.get_running_loop()
357
+ while not bool(getattr(self._connection, "is_closed", False)):
358
+ await self.poll(loop.time())
359
+ await asyncio.sleep(self._poll_interval)
360
+
361
+ async def poll(self, now: float) -> None:
362
+ """One observation. Public so tests can drive it without sleeping."""
363
+ event = aiormq_unblocked_event(self._connection)
364
+ if event is None:
365
+ return # no live transport (reconnecting): keep the last state
366
+ underlay = self._connection.transport.connection
367
+ if underlay is not self._underlay:
368
+ # A new AMQP connection starts unblocked; any earlier alarm
369
+ # belonged to the old one, so its timer must not carry over.
370
+ self._underlay = underlay
371
+ self._blocked_since = now if self._blocked else None
372
+ blocked = not event.is_set()
373
+ if blocked and not self._blocked:
374
+ self._blocked = True
375
+ self._blocked_since = now
376
+ if self._blocked_timeout > 0:
377
+ logger.warning(
378
+ "Connection blocked by RabbitMQ; forcing a reconnect in %.1fs if not unblocked",
379
+ self._blocked_timeout,
380
+ )
381
+ else:
382
+ logger.warning("Connection blocked by RabbitMQ (resource alarm)")
383
+ self._fire(self._on_blocked)
384
+ elif not blocked and self._blocked:
385
+ self._blocked = False
386
+ self._blocked_since = None
387
+ logger.info("Connection unblocked by RabbitMQ")
388
+ self._fire(self._on_unblocked)
389
+ if (
390
+ self._blocked
391
+ and self._blocked_timeout > 0
392
+ and self._blocked_since is not None
393
+ and now - self._blocked_since >= self._blocked_timeout
394
+ ):
395
+ self._blocked_since = None # one close per alarm, not one per poll
396
+ if self._may_force_reconnect is None or self._may_force_reconnect():
397
+ await self._force_reconnect(underlay)
398
+
399
+ async def _force_reconnect(self, underlay: Any) -> None:
400
+ logger.warning(
401
+ "Connection blocked for > %.1fs; closing it so the robust connection reconnects",
402
+ self._blocked_timeout,
403
+ )
404
+ self.forced_reconnects += 1
405
+ try:
406
+ await underlay.close(
407
+ ConnectionError(f"connection blocked by RabbitMQ for more than {self._blocked_timeout:.1f}s")
408
+ )
409
+ except Exception: # pragma: no cover — best effort; connect_robust still retries
410
+ logger.debug("closing the blocked connection raised", exc_info=True)
411
+
412
+ @staticmethod
413
+ def _fire(callback: Callable[[], None] | None) -> None:
414
+ if callback is None:
415
+ return
416
+ try:
417
+ callback()
418
+ except Exception: # pragma: no cover — never break the poll loop
419
+ logger.exception("blocked/unblocked callback raised")