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.
- {rabbitkit-0.17.0/src/rabbitkit.egg-info → rabbitkit-0.19.0}/PKG-INFO +2 -2
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/pyproject.toml +2 -2
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/__init__.py +2 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/_version.py +1 -1
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/broker.py +59 -13
- rabbitkit-0.19.0/src/rabbitkit/async_/connection.py +419 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/transport.py +222 -75
- rabbitkit-0.19.0/src/rabbitkit/cli/commands/dlq.py +403 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/config.py +18 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/errors.py +11 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/message.py +5 -0
- rabbitkit-0.19.0/src/rabbitkit/core/quorum.py +159 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/types.py +27 -1
- rabbitkit-0.19.0/src/rabbitkit/dlq.py +695 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/deduplication.py +10 -1
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/retry.py +30 -6
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/broker.py +59 -13
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/transport.py +82 -3
- {rabbitkit-0.17.0 → rabbitkit-0.19.0/src/rabbitkit.egg-info}/PKG-INFO +2 -2
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/SOURCES.txt +1 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/requires.txt +1 -1
- rabbitkit-0.17.0/src/rabbitkit/async_/connection.py +0 -287
- rabbitkit-0.17.0/src/rabbitkit/cli/commands/dlq.py +0 -190
- rabbitkit-0.17.0/src/rabbitkit/dlq.py +0 -336
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/LICENSE +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/README.md +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/setup.cfg +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/aio/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/batch.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/async_/pool.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/asyncapi/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/asyncapi/generator.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/asyncapi/schema.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/_utils.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/health.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/migrate.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/routes.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/run.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/shell.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/cli/commands/topology.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/concurrency.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/app.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/bulk.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/bulk_runner.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/env_config.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/logging.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/path.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/pipeline.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/profiles.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/protocols.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/registry.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/retry_handoff.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/route.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/router.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/sanitizer.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/settlement.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/topology.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/core/topology_dispatch.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/dashboard/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/dashboard/app.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/di/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/di/context.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/di/depends.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/di/resolver.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/experimental/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/fastapi.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/health.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/highload/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/highload/async_acker.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/highload/backpressure.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/highload/batch.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/locking.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/management.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/base.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/circuit_breaker.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/compression.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/error_classifier.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/exception.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/metrics.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/otel.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/rate_limit.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/signing.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/middleware/timeout.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/py.typed +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/queue_metrics.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/results/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/results/backend.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/results/middleware.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/rpc.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/base.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/json.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/msgspec.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/serialization/pipeline.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/streams.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/batch.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/connection.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/sync/pool.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/testing/__init__.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/testing/app.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/testing/broker.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit/testing/fixtures.py +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/dependency_links.txt +0 -0
- {rabbitkit-0.17.0 → rabbitkit-0.19.0}/src/rabbitkit.egg-info/entry_points.txt +0 -0
- {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.
|
|
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<
|
|
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.
|
|
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,<
|
|
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__",
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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")
|