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,274 @@
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 logging
11
+ import random
12
+ import ssl
13
+ from typing import Any
14
+ from urllib.parse import quote
15
+
16
+ from rabbitkit.core.config import ConnectionConfig, SecurityConfig, SSLConfig
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # ── Transport-specific connection errors ──────────────────────────────────
22
+
23
+
24
+ def get_connection_errors() -> tuple[type[BaseException], ...]:
25
+ """Get aio-pika-specific connection error tuple.
26
+
27
+ Returns generic stdlib errors if aio-pika is not installed.
28
+ """
29
+ base_errors: tuple[type[BaseException], ...] = (
30
+ ConnectionResetError,
31
+ BrokenPipeError,
32
+ ConnectionAbortedError,
33
+ ConnectionRefusedError,
34
+ TimeoutError,
35
+ EOFError,
36
+ OSError,
37
+ )
38
+
39
+ try:
40
+ import aio_pika.exceptions
41
+
42
+ aio_pika_errors: tuple[type[BaseException], ...] = (
43
+ aio_pika.exceptions.AMQPConnectionError,
44
+ aio_pika.exceptions.ChannelClosed,
45
+ aio_pika.exceptions.ConnectionClosed,
46
+ )
47
+ return aio_pika_errors + base_errors
48
+ except (ImportError, AttributeError):
49
+ return base_errors
50
+
51
+
52
+ def build_ssl_context(ssl_config: SSLConfig) -> ssl.SSLContext | None:
53
+ """Build stdlib ssl.SSLContext from SSLConfig.
54
+
55
+ Returns None if SSL is not enabled.
56
+ Shared with sync/connection.py — same logic.
57
+ """
58
+ if not ssl_config.enabled:
59
+ return None
60
+
61
+ cert_reqs_map = {
62
+ "CERT_REQUIRED": ssl.CERT_REQUIRED,
63
+ "CERT_OPTIONAL": ssl.CERT_OPTIONAL,
64
+ "CERT_NONE": ssl.CERT_NONE,
65
+ }
66
+ cert_reqs = cert_reqs_map.get(ssl_config.cert_reqs, ssl.CERT_REQUIRED)
67
+
68
+ # M13: disabling certificate verification makes the connection MITM-able —
69
+ # warn loudly (see sync/connection.py for rationale).
70
+ if cert_reqs == ssl.CERT_NONE:
71
+ import warnings
72
+
73
+ warnings.warn(
74
+ "SSLConfig(cert_reqs='CERT_NONE') disables TLS certificate and hostname "
75
+ "verification — the connection is encrypted but MITM-able. Use "
76
+ "'CERT_REQUIRED' (the default) with a proper ca_certs bundle in production.",
77
+ RuntimeWarning,
78
+ stacklevel=2,
79
+ )
80
+
81
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
82
+
83
+ # Defense in depth: never negotiate below TLS 1.2.
84
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
85
+
86
+ # check_hostname must be disabled BEFORE setting verify_mode=CERT_NONE
87
+ if cert_reqs == ssl.CERT_NONE:
88
+ ctx.check_hostname = False
89
+
90
+ ctx.verify_mode = cert_reqs
91
+
92
+ if ssl_config.ca_certs:
93
+ ctx.load_verify_locations(ssl_config.ca_certs)
94
+ elif cert_reqs == ssl.CERT_REQUIRED:
95
+ # No explicit CA bundle configured — fall back to the system trust
96
+ # store so verification actually succeeds against broker certs
97
+ # signed by a well-known CA. Guarded so the explicit-ca_certs path
98
+ # above is unchanged.
99
+ try:
100
+ ctx.load_default_certs()
101
+ except Exception: # pragma: no cover — best effort, platform-dependent
102
+ try:
103
+ ctx.set_default_verify_paths()
104
+ except Exception: # pragma: no cover
105
+ pass
106
+
107
+ if ssl_config.certfile:
108
+ ctx.load_cert_chain(
109
+ certfile=ssl_config.certfile,
110
+ keyfile=ssl_config.keyfile,
111
+ )
112
+
113
+ return ctx
114
+
115
+
116
+ def make_aio_pika_connect_kwargs(
117
+ connection: ConnectionConfig,
118
+ security: SecurityConfig,
119
+ *,
120
+ host_override: str | None = None,
121
+ port_override: int | None = None,
122
+ ) -> dict[str, Any]:
123
+ """Build kwargs for aio_pika.connect_robust().
124
+
125
+ Returns a dict of keyword arguments.
126
+ Raises ImportError if aio-pika is not installed.
127
+
128
+ Note: aio-pika has no native ``blocked_connection_timeout`` knob (unlike
129
+ pika's ``ConnectionParameters.blocked_connection_timeout``). To honour
130
+ ``ConnectionConfig.blocked_connection_timeout`` on the async side, call
131
+ :func:`install_blocked_connection_watchdog` on the returned connection
132
+ after ``connect_robust`` succeeds. That helper drives a timer task that
133
+ closes the connection when a ``connection.blocked`` alarm is not cleared
134
+ by ``connection.unblocked`` within the configured timeout, forcing a
135
+ reconnect instead of stalling publishes indefinitely.
136
+ """
137
+ try:
138
+ import aio_pika # noqa: F401
139
+ except ImportError:
140
+ raise ImportError(
141
+ "aio-pika is required for async transport. Install it with: pip install rabbitkit[async]"
142
+ ) from None
143
+
144
+ # Build URL — carry heartbeat as a query param. aio-pika/aiormq read heartbeat
145
+ # from the URL; passing it as a kwarg is not portable across versions. Without
146
+ # this the configured ConnectionConfig.heartbeat was silently dropped on async
147
+ # (the sync transport already honors it).
148
+ #
149
+ # URL-encode username/password so credentials containing special characters
150
+ # (e.g. ":", "@", "/", "+") don't corrupt the AMQP URL or leak as plaintext
151
+ # delimiters. ConnectionConfig.url (core/config.py) is NOT in this module's
152
+ # scope, so we rebuild a safe URL here.
153
+ # M13: resolve credentials via credentials_provider if set (rotation).
154
+ raw_user, raw_pwd = connection.resolve_credentials()
155
+ user = quote(raw_user, safe="")
156
+ pwd = quote(raw_pwd, safe="")
157
+ vhost = connection.vhost
158
+ if vhost == "/":
159
+ vhost = "%2F"
160
+ # M9: allow the caller (pool) to target a specific cluster node.
161
+ host = host_override if host_override is not None else connection.host
162
+ port = port_override if port_override is not None else connection.port
163
+ base_url = f"amqp://{user}:{pwd}@{host}:{port}/{vhost}"
164
+ sep = "&" if "?" in base_url else "?"
165
+ url = f"{base_url}{sep}heartbeat={connection.heartbeat}"
166
+
167
+ # H4: aio-pika's connect_robust uses a FIXED reconnect_interval — no
168
+ # exponential backoff. A fleet of consumers restarted together (e.g. a
169
+ # broker bounce under 200 pods) would otherwise retry in lockstep every
170
+ # `reconnect_backoff_base` seconds, hammering the recovering node in a
171
+ # thundering herd. We can't inject exponential backoff into connect_robust,
172
+ # but randomizing the interval PER PROCESS (full jitter over
173
+ # [base, min(base*2, backoff_max)]) de-synchronizes the herd so retries
174
+ # spread across the window instead of arriving as a spike. Each process
175
+ # picks its interval once at connect time; `random` needs no seeding for
176
+ # inter-process spread since PYTHONHASHSEED/os entropy differ per process.
177
+ base = connection.reconnect_backoff_base
178
+ jitter_span = max(0.0, min(base, connection.reconnect_backoff_max - base))
179
+ jittered_interval = base + random.uniform(0.0, jitter_span) # noqa: S311 — jitter, not crypto
180
+ kwargs: dict[str, Any] = {
181
+ "url": url,
182
+ "timeout": connection.socket_timeout,
183
+ "reconnect_interval": jittered_interval,
184
+ }
185
+
186
+ # SSL
187
+ ssl_context = build_ssl_context(security.ssl)
188
+ if ssl_context is not None:
189
+ kwargs["ssl_context"] = ssl_context
190
+
191
+ # Client properties
192
+ if connection.connection_name:
193
+ kwargs["client_properties"] = {
194
+ "connection_name": connection.connection_name,
195
+ }
196
+
197
+ return kwargs
198
+
199
+
200
+ async def install_blocked_connection_watchdog(connection: Any, blocked_timeout: float) -> None:
201
+ """Install a watchdog that closes *connection* when a blocked alarm lingers.
202
+
203
+ aio-pika has no native ``blocked_connection_timeout`` (I-11), so without
204
+ this a ``connection.blocked`` alarm from RabbitMQ (memory/disk pressure)
205
+ can stall publishes indefinitely while the connection stays "open". This
206
+ helper registers ``connection.connection_blocked`` /
207
+ ``connection.connection_unblocked`` callbacks that drive a timer task:
208
+
209
+ - on ``blocked``: start (or replace) a task that sleeps *blocked_timeout*
210
+ then closes the connection (forcing ``connect_robust`` to reconnect).
211
+ - on ``unblocked``: cancel the pending timer so a transient alarm does not
212
+ tear down a recovered connection.
213
+
214
+ Safe to call with ``blocked_timeout <= 0`` (no-op) or on a connection
215
+ that does not expose the callback collections (logged at debug, no raise).
216
+ Must be called from the event loop that owns *connection*.
217
+ """
218
+ if blocked_timeout <= 0:
219
+ return
220
+
221
+ blocked_cb_collection = getattr(connection, "connection_blocked", None)
222
+ unblocked_cb_collection = getattr(connection, "connection_unblocked", None)
223
+ if blocked_cb_collection is None or unblocked_cb_collection is None:
224
+ logger.debug(
225
+ "connection does not expose connection_blocked/connection_unblocked "
226
+ "callback collections; blocked-connection watchdog not installed"
227
+ )
228
+ return
229
+
230
+ loop = asyncio.get_event_loop()
231
+ state: dict[str, asyncio.Task[None] | None] = {"timer": None}
232
+
233
+ async def _on_blocked(*_args: Any) -> None:
234
+ # Replace any pending timer with a fresh one.
235
+ existing = state.get("timer")
236
+ if existing is not None and not existing.done():
237
+ existing.cancel()
238
+ logger.warning(
239
+ "Connection blocked by RabbitMQ; will close in %.1fs if not unblocked",
240
+ blocked_timeout,
241
+ )
242
+ state["timer"] = asyncio.ensure_future(_close_after(blocked_timeout))
243
+
244
+ async def _on_unblocked(*_args: Any) -> None:
245
+ existing = state.get("timer")
246
+ if existing is not None and not existing.done():
247
+ existing.cancel()
248
+ state["timer"] = None
249
+ logger.info("Connection unblocked; watchdog timer cancelled")
250
+
251
+ async def _close_after(delay: float) -> None:
252
+ try:
253
+ await asyncio.sleep(delay)
254
+ except asyncio.CancelledError:
255
+ return
256
+ logger.warning("Connection blocked for > %.1fs; closing to force reconnect", delay)
257
+ try:
258
+ close = connection.close
259
+ result = close()
260
+ if hasattr(result, "__await__"):
261
+ await result
262
+ except Exception: # pragma: no cover — best effort; connect_robust will retry
263
+ logger.debug("watchdog close raised", exc_info=True)
264
+
265
+ # aio-pika CallbackCollection.add_callback accepts a coroutine fn.
266
+ try:
267
+ blocked_cb_collection.add_callback(_on_blocked)
268
+ unblocked_cb_collection.add_callback(_on_unblocked)
269
+ except Exception: # pragma: no cover — defensive across aio-pika versions
270
+ logger.debug("Could not register blocked/unblocked watchdog callbacks", exc_info=True)
271
+ return
272
+ # Keep a reference so the timer is not GC'd and the callbacks are traceable.
273
+ connection._rabbitkit_blocked_watchdog = state
274
+ connection._rabbitkit_blocked_watchdog_loop = loop
@@ -0,0 +1,363 @@
1
+ """Async connection and channel pools.
2
+
3
+ Minimal in 0.1.0 — internal performance utilities.
4
+ Do not oversell as a promised optimization layer.
5
+
6
+ Uses asyncio.Queue for channel pooling and dedicated
7
+ connections for publisher vs consumer separation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import random
15
+ from contextlib import asynccontextmanager
16
+ from typing import Any
17
+
18
+ from rabbitkit.async_.connection import get_connection_errors, make_aio_pika_connect_kwargs
19
+ from rabbitkit.core.config import ConnectionConfig, PoolConfig, SecurityConfig
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class AsyncChannelPool:
25
+ """Async channel pool.
26
+
27
+ Manages a pool of aio-pika channels on a single connection.
28
+ Channels are acquired and released by callers.
29
+
30
+ ``acquire_timeout`` controls how long to wait when all channels are
31
+ checked out. Raises ``asyncio.TimeoutError`` on exhaustion rather than
32
+ blocking forever (which would deadlock if a handler tries to publish).
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ connection: Any, # aio_pika.RobustConnection
38
+ pool_size: int = 10,
39
+ acquire_timeout: float = 10.0,
40
+ publisher_confirms: bool = True,
41
+ ) -> None:
42
+ self._connection = connection
43
+ self._pool_size = pool_size
44
+ self._acquire_timeout = acquire_timeout
45
+ self._publisher_confirms = publisher_confirms
46
+ self._pool: asyncio.Queue[Any] = asyncio.Queue(maxsize=pool_size)
47
+ self._lock = asyncio.Lock()
48
+ self._created = 0
49
+ # Channels currently checked out by callers; closed in close_all() so they
50
+ # are not orphaned if a caller forgets to release (leak detection).
51
+ self._in_use: set[Any] = set()
52
+
53
+ async def acquire(self) -> Any:
54
+ """Acquire a channel from the pool.
55
+
56
+ Creates a new channel if the pool is empty and under the size limit.
57
+ Waits up to ``acquire_timeout`` seconds when all channels are in use;
58
+ raises ``asyncio.TimeoutError`` if the wait expires, preventing
59
+ deadlocks when handlers publish while processing messages.
60
+ """
61
+ try:
62
+ channel = self._pool.get_nowait()
63
+ if not channel.is_closed:
64
+ async with self._lock:
65
+ self._in_use.add(channel)
66
+ return channel
67
+ # I-6: a pooled channel was found closed — it still counted
68
+ # against _created when it was released, so decrement before
69
+ # discarding it (otherwise acquire() leaks a slot each time a
70
+ # closed-idle channel is pulled from the pool).
71
+ async with self._lock:
72
+ self._created = max(0, self._created - 1)
73
+ except asyncio.QueueEmpty:
74
+ pass
75
+
76
+ # perf-M-2: create the channel OUTSIDE the lock (network round-trip) so
77
+ # concurrent acquire() calls don't serialize on channel creation during
78
+ # warmup/refill. The slot is reserved atomically under the lock (so we
79
+ # never over-create); the channel-open I/O happens outside the lock, and
80
+ # we re-acquire only to publish _in_use. If creation fails, the reserved
81
+ # slot is returned. The I-6 closed-idle decrement above stays under the
82
+ # lock.
83
+ async with self._lock:
84
+ if self._created < self._pool_size:
85
+ need_create = True
86
+ self._created += 1
87
+ else:
88
+ need_create = False
89
+
90
+ if need_create:
91
+ try:
92
+ channel = await self._connection.channel(publisher_confirms=self._publisher_confirms)
93
+ except BaseException:
94
+ # creation failed — give the reserved slot back.
95
+ async with self._lock:
96
+ self._created = max(0, self._created - 1)
97
+ raise
98
+ async with self._lock:
99
+ self._in_use.add(channel)
100
+ return channel
101
+
102
+ # Pool exhausted — wait with timeout to avoid deadlocks. R-timeout:
103
+ # ``asyncio.timeout`` (3.11+) replaces ``asyncio.wait_for`` to avoid
104
+ # the wrapper-task overhead.
105
+ logger.warning(
106
+ "Channel pool exhausted (pool_size=%d, created=%d). "
107
+ "Waiting up to %.1fs for a channel to be released. "
108
+ "Consider increasing PoolConfig.channel_pool_size.",
109
+ self._pool_size,
110
+ self._created,
111
+ self._acquire_timeout,
112
+ )
113
+ try:
114
+ async with asyncio.timeout(self._acquire_timeout):
115
+ channel = await self._pool.get()
116
+ except TimeoutError:
117
+ raise TimeoutError(
118
+ f"Timed out after {self._acquire_timeout}s waiting for a channel "
119
+ f"from the pool (pool_size={self._pool_size})."
120
+ ) from None
121
+ if channel.is_closed:
122
+ async with self._lock:
123
+ self._created = max(0, self._created - 1)
124
+ return await self.acquire()
125
+ async with self._lock:
126
+ self._in_use.add(channel)
127
+ return channel
128
+
129
+ async def release(self, channel: Any) -> None:
130
+ """Release a channel back to the pool."""
131
+ async with self._lock:
132
+ self._in_use.discard(channel)
133
+ if not channel.is_closed:
134
+ try:
135
+ self._pool.put_nowait(channel)
136
+ return
137
+ except asyncio.QueueFull:
138
+ pass
139
+ # Channel is closed or pool is full — discard
140
+ try:
141
+ if not channel.is_closed:
142
+ await channel.close()
143
+ except Exception:
144
+ pass
145
+ async with self._lock:
146
+ self._created = max(0, self._created - 1)
147
+
148
+ @asynccontextmanager
149
+ async def acquire_ctx(self) -> Any:
150
+ """Async context manager for acquire/release — prevents leaks.
151
+
152
+ Usage::
153
+
154
+ async with pool.acquire_ctx() as ch:
155
+ await ch.publish(...)
156
+ """
157
+ channel = await self.acquire()
158
+ try:
159
+ yield channel
160
+ finally:
161
+ await self.release(channel)
162
+
163
+ async def close_all(self) -> None:
164
+ """Close all channels in the pool, including checked-out ones."""
165
+ async with self._lock:
166
+ in_use = list(self._in_use)
167
+ self._in_use.clear()
168
+ for channel in in_use:
169
+ try:
170
+ if not channel.is_closed:
171
+ await channel.close()
172
+ except Exception: # pragma: no cover — best effort
173
+ pass
174
+ async with self._lock:
175
+ self._created = max(0, self._created - 1)
176
+ while not self._pool.empty():
177
+ try:
178
+ channel = self._pool.get_nowait()
179
+ if not channel.is_closed:
180
+ await channel.close()
181
+ except (asyncio.QueueEmpty, Exception):
182
+ pass
183
+ async with self._lock:
184
+ self._created = 0
185
+
186
+ @property
187
+ def size(self) -> int:
188
+ """Number of channels currently in the pool (available)."""
189
+ return self._pool.qsize()
190
+
191
+ @property
192
+ def created_count(self) -> int:
193
+ """Total number of channels created."""
194
+ return self._created
195
+
196
+
197
+ class AsyncConnectionPool:
198
+ """Separate publisher/consumer connections with channel pools.
199
+
200
+ Provides dedicated connections for publishing and consuming to avoid
201
+ head-of-line blocking, and exposes ``AsyncChannelPool`` instances so
202
+ callers never share a single channel across concurrent operations.
203
+
204
+ Usage::
205
+
206
+ pool = AsyncConnectionPool(connection_config, security_config, pool_config)
207
+ await pool.connect()
208
+
209
+ async with pool.acquire_publisher_channel() as ch:
210
+ await ch.publish(...)
211
+
212
+ await pool.close_all()
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ connection_config: ConnectionConfig,
218
+ security_config: SecurityConfig,
219
+ pool_config: PoolConfig | None = None,
220
+ publisher_confirms: bool = True,
221
+ ) -> None:
222
+ self._connection_config = connection_config
223
+ self._security_config = security_config
224
+ self._pool_config = pool_config or PoolConfig()
225
+ self._publisher_confirms = publisher_confirms
226
+
227
+ self._publisher_connection: Any | None = None
228
+ self._consumer_connection: Any | None = None
229
+ self._publisher_channel_pool: AsyncChannelPool | None = None
230
+ self._lock = asyncio.Lock()
231
+ self._prewarmed = False
232
+
233
+ async def connect(self) -> None:
234
+ """Establish publisher and consumer connections eagerly."""
235
+ do_prewarm = False
236
+ async with self._lock:
237
+ if self._publisher_connection is None:
238
+ self._publisher_connection = await self._create_connection()
239
+ self._publisher_channel_pool = AsyncChannelPool(
240
+ self._publisher_connection,
241
+ pool_size=self._pool_config.channel_pool_size,
242
+ acquire_timeout=self._pool_config.channel_acquire_timeout,
243
+ publisher_confirms=self._publisher_confirms,
244
+ )
245
+ if self._consumer_connection is None:
246
+ self._consumer_connection = await self._create_connection()
247
+ if self._pool_config.prewarm_channels and not self._prewarmed:
248
+ self._prewarmed = True
249
+ do_prewarm = True
250
+
251
+ if do_prewarm and self._publisher_channel_pool is not None:
252
+ pool = self._publisher_channel_pool
253
+ channels = await asyncio.gather(
254
+ *(pool.acquire() for _ in range(self._pool_config.channel_pool_size)),
255
+ return_exceptions=True,
256
+ )
257
+ for ch in channels:
258
+ if not isinstance(ch, BaseException):
259
+ await pool.release(ch)
260
+
261
+ async def get_publisher_connection(self) -> Any:
262
+ """Get a connection dedicated for publishing.
263
+
264
+ Creates the connection lazily on first call.
265
+ """
266
+ async with self._lock:
267
+ if self._publisher_connection is None:
268
+ self._publisher_connection = await self._create_connection()
269
+ self._publisher_channel_pool = AsyncChannelPool(
270
+ self._publisher_connection,
271
+ pool_size=self._pool_config.channel_pool_size,
272
+ acquire_timeout=self._pool_config.channel_acquire_timeout,
273
+ publisher_confirms=self._publisher_confirms,
274
+ )
275
+ return self._publisher_connection
276
+
277
+ async def get_consumer_connection(self) -> Any:
278
+ """Get a connection dedicated for consuming.
279
+
280
+ Creates the connection lazily on first call.
281
+ """
282
+ async with self._lock:
283
+ if self._consumer_connection is None:
284
+ self._consumer_connection = await self._create_connection()
285
+ return self._consumer_connection
286
+
287
+ async def acquire_publisher_channel(self) -> Any:
288
+ """Acquire a channel from the publisher channel pool."""
289
+ if self._publisher_channel_pool is None:
290
+ await self.get_publisher_connection()
291
+ assert self._publisher_channel_pool is not None
292
+ return await self._publisher_channel_pool.acquire()
293
+
294
+ async def release_publisher_channel(self, channel: Any) -> None:
295
+ """Return a publisher channel to the pool."""
296
+ if self._publisher_channel_pool is not None:
297
+ await self._publisher_channel_pool.release(channel)
298
+
299
+ async def close_all(self) -> None:
300
+ """Close all channel pools and connections."""
301
+ async with self._lock:
302
+ if self._publisher_channel_pool is not None:
303
+ await self._publisher_channel_pool.close_all()
304
+ self._publisher_channel_pool = None
305
+
306
+ for conn in [self._publisher_connection, self._consumer_connection]:
307
+ if conn is not None:
308
+ try:
309
+ if not conn.is_closed:
310
+ await conn.close()
311
+ except Exception as e:
312
+ logger.warning("Error closing connection: %s", e)
313
+
314
+ self._publisher_connection = None
315
+ self._consumer_connection = None
316
+
317
+ async def _create_connection(self) -> Any:
318
+ """Create a new aio-pika connection."""
319
+ try:
320
+ import aio_pika
321
+ except ImportError:
322
+ raise ImportError(
323
+ "aio-pika is required for async transport. Install it with: pip install rabbitkit[async]"
324
+ ) from None
325
+
326
+ # M9: cycle through cluster endpoints on the initial connect so a dead
327
+ # configured primary doesn't take the client down at startup. Once
328
+ # connect_robust succeeds it pins to that node for reconnects (aio-pika
329
+ # has no multi-host reconnect) — put a load balancer / DNS in front for
330
+ # per-reconnect failover across nodes.
331
+ endpoints = self._connection_config.cluster_endpoints()
332
+
333
+ # H-SRE3: connect_robust handles reconnects AFTER the first connection
334
+ # with a FIXED interval, so a fleet of clients starting at once thunder
335
+ # the broker. Apply an outer retry with full jitter for the INITIAL
336
+ # connect only; bounded so we never spin forever.
337
+ backoff = self._connection_config.reconnect_backoff_base
338
+ max_backoff = self._connection_config.reconnect_backoff_max
339
+ connection_errors = get_connection_errors()
340
+ max_attempts = 30
341
+ for attempt in range(1, max_attempts + 1):
342
+ host, port = endpoints[(attempt - 1) % len(endpoints)]
343
+ kwargs = make_aio_pika_connect_kwargs(
344
+ self._connection_config,
345
+ self._security_config,
346
+ host_override=host,
347
+ port_override=port,
348
+ )
349
+ try:
350
+ return await aio_pika.connect_robust(**kwargs)
351
+ except connection_errors as e:
352
+ if attempt == max_attempts:
353
+ raise
354
+ sleep_for = random.uniform(0.0, backoff) # noqa: S311
355
+ logger.warning(
356
+ "aio-pika initial connect failed (attempt %d), retrying in %.2fs: %s",
357
+ attempt,
358
+ sleep_for,
359
+ e,
360
+ )
361
+ await asyncio.sleep(sleep_for)
362
+ backoff = min(backoff * 2, max_backoff)
363
+ raise RuntimeError("unreachable") # pragma: no cover