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
rabbitkit/__init__.py ADDED
@@ -0,0 +1,201 @@
1
+ """rabbitkit — Production-grade RabbitMQ toolkit."""
2
+
3
+ from rabbitkit import experimental
4
+ from rabbitkit._version import __version__
5
+ from rabbitkit.async_.batch import AsyncBatchPublisher
6
+ from rabbitkit.async_.broker import AsyncBroker
7
+ from rabbitkit.asyncapi import AsyncAPIGeneratorConfig, generate_asyncapi_doc, generate_asyncapi_json
8
+ from rabbitkit.concurrency import AsyncWorkerPool, SyncWorkerPool
9
+ from rabbitkit.core.app import AppState, RabbitApp
10
+ from rabbitkit.core.config import (
11
+ RETRY_DISABLED,
12
+ BackpressureConfig,
13
+ BatchAckConfig,
14
+ BatchPublishConfig,
15
+ CompressionConfig,
16
+ ConnectionConfig,
17
+ ConsumerConfig,
18
+ DeduplicationConfig,
19
+ HealthCheckConfig,
20
+ MetricsConfig,
21
+ PoolConfig,
22
+ PublisherConfig,
23
+ RabbitConfig,
24
+ RetryConfig,
25
+ RetryDisabled,
26
+ SafetyConfig,
27
+ SecurityConfig,
28
+ SocketConfig,
29
+ SSLConfig,
30
+ WorkerConfig,
31
+ )
32
+ from rabbitkit.core.errors import (
33
+ BackpressureError,
34
+ ConfigurationError,
35
+ MissingDependencyError,
36
+ UnsafeTopologyError,
37
+ )
38
+ from rabbitkit.core.logging import DEFAULT_REDACT_KEYS, LoggingConfig, configure_structlog
39
+ from rabbitkit.core.message import AckMessage, NackMessage, RabbitMessage, RejectMessage
40
+ from rabbitkit.core.router import RabbitRouter
41
+ from rabbitkit.core.topology import RabbitExchange, RabbitQueue
42
+ from rabbitkit.core.types import (
43
+ AckPolicy,
44
+ ClassifiedError,
45
+ DeduplicationMarkPolicy,
46
+ ErrorSeverity,
47
+ ExchangeType,
48
+ MessageEnvelope,
49
+ PublishOutcome,
50
+ PublishStatus,
51
+ QueueType,
52
+ RejectWithoutDLXPolicy,
53
+ TopologyMode,
54
+ )
55
+ from rabbitkit.di import Context, ContextRepo, Depends, DIResolver, Header, Path
56
+ from rabbitkit.di.resolver import DependencyScope
57
+ from rabbitkit.dlq import DLQInspector, ReplayResult
58
+ from rabbitkit.fastapi import rabbitkit_lifespan
59
+ from rabbitkit.health import (
60
+ AsyncHealthWatcher,
61
+ BrokerHealthResult,
62
+ HealthStatus,
63
+ HealthWatcher,
64
+ broker_health_check,
65
+ broker_health_check_async,
66
+ broker_liveness,
67
+ broker_liveness_async,
68
+ broker_readiness,
69
+ broker_readiness_async,
70
+ )
71
+ from rabbitkit.highload.backpressure import FlowController
72
+ from rabbitkit.highload.batch import BatchAcker, BatchPublisher
73
+ from rabbitkit.management import ManagementConfig, RabbitManagementClient
74
+ from rabbitkit.middleware.circuit_breaker import CircuitBreakerMiddleware, CircuitBreakerOpenError
75
+ from rabbitkit.middleware.deduplication import DeduplicationMiddleware
76
+ from rabbitkit.middleware.metrics import (
77
+ MetricsCollector,
78
+ MetricsMiddleware,
79
+ PrometheusCollector,
80
+ metrics_app,
81
+ start_metrics_server,
82
+ )
83
+ from rabbitkit.middleware.otel import OTelTracingMiddleware
84
+ from rabbitkit.middleware.rate_limit import RateLimitConfig, RateLimitMiddleware
85
+ from rabbitkit.queue_metrics import QueueMetricsPoller
86
+ from rabbitkit.serialization.pipeline import (
87
+ DataclassDecoder,
88
+ JsonParser,
89
+ MessageDecoder,
90
+ MessageParser,
91
+ PydanticDecoder,
92
+ RawDecoder,
93
+ SerializationPipeline,
94
+ )
95
+ from rabbitkit.sync.batch import SyncBatchPublisher
96
+ from rabbitkit.sync.broker import SyncBroker
97
+
98
+ __all__ = [
99
+ "DEFAULT_REDACT_KEYS",
100
+ "RETRY_DISABLED",
101
+ "AckMessage",
102
+ "AckPolicy",
103
+ "AppState",
104
+ "AsyncAPIGeneratorConfig",
105
+ "AsyncBatchPublisher",
106
+ "AsyncBroker",
107
+ "AsyncHealthWatcher",
108
+ "AsyncWorkerPool",
109
+ "BackpressureConfig",
110
+ "BackpressureError",
111
+ "BatchAckConfig",
112
+ "BatchAcker",
113
+ "BatchPublishConfig",
114
+ "BatchPublisher",
115
+ "BrokerHealthResult",
116
+ "CircuitBreakerMiddleware",
117
+ "CircuitBreakerOpenError",
118
+ "ClassifiedError",
119
+ "CompressionConfig",
120
+ "ConfigurationError",
121
+ "ConnectionConfig",
122
+ "ConsumerConfig",
123
+ "Context",
124
+ "ContextRepo",
125
+ "DIResolver",
126
+ "DLQInspector",
127
+ "DataclassDecoder",
128
+ "DeduplicationConfig",
129
+ "DeduplicationMarkPolicy",
130
+ "DeduplicationMiddleware",
131
+ "DependencyScope",
132
+ "Depends",
133
+ "ErrorSeverity",
134
+ "ExchangeType",
135
+ "FlowController",
136
+ "Header",
137
+ "HealthCheckConfig",
138
+ "HealthStatus",
139
+ "HealthWatcher",
140
+ "JsonParser",
141
+ "LoggingConfig",
142
+ "ManagementConfig",
143
+ "MessageDecoder",
144
+ "MessageEnvelope",
145
+ "MessageParser",
146
+ "MetricsCollector",
147
+ "MetricsConfig",
148
+ "MetricsMiddleware",
149
+ "MissingDependencyError",
150
+ "NackMessage",
151
+ "OTelTracingMiddleware",
152
+ "Path",
153
+ "PoolConfig",
154
+ "PrometheusCollector",
155
+ "PublishOutcome",
156
+ "PublishStatus",
157
+ "PublisherConfig",
158
+ "PydanticDecoder",
159
+ "QueueMetricsPoller",
160
+ "QueueType",
161
+ "RabbitApp",
162
+ "RabbitConfig",
163
+ "RabbitExchange",
164
+ "RabbitManagementClient",
165
+ "RabbitMessage",
166
+ "RabbitQueue",
167
+ "RabbitRouter",
168
+ "RateLimitConfig",
169
+ "RateLimitMiddleware",
170
+ "RawDecoder",
171
+ "RejectMessage",
172
+ "RejectWithoutDLXPolicy",
173
+ "ReplayResult",
174
+ "RetryConfig",
175
+ "RetryDisabled",
176
+ "SSLConfig",
177
+ "SafetyConfig",
178
+ "SecurityConfig",
179
+ "SerializationPipeline",
180
+ "SocketConfig",
181
+ "SyncBatchPublisher",
182
+ "SyncBroker",
183
+ "SyncWorkerPool",
184
+ "TopologyMode",
185
+ "UnsafeTopologyError",
186
+ "WorkerConfig",
187
+ "__version__",
188
+ "broker_health_check",
189
+ "broker_health_check_async",
190
+ "broker_liveness",
191
+ "broker_liveness_async",
192
+ "broker_readiness",
193
+ "broker_readiness_async",
194
+ "configure_structlog",
195
+ "experimental",
196
+ "generate_asyncapi_doc",
197
+ "generate_asyncapi_json",
198
+ "metrics_app",
199
+ "rabbitkit_lifespan",
200
+ "start_metrics_server",
201
+ ]
rabbitkit/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """rabbitkit version — single source of truth."""
2
+
3
+ __version__ = "0.9.0"
@@ -0,0 +1,31 @@
1
+ """rabbitkit.aio — deprecated alias for ``rabbitkit.async_`` (L8).
2
+
3
+ ``rabbitkit.async_`` is the canonical import path (it matches the actual
4
+ module structure — ``async_/broker.py``, ``async_/transport.py``, etc. — and
5
+ is what every example, doc, and test in this codebase uses). This module is
6
+ kept only so any existing ``from rabbitkit.aio import ...`` import keeps
7
+ working; importing it emits a ``DeprecationWarning``.
8
+
9
+ Prefer::
10
+
11
+ from rabbitkit.async_.broker import AsyncBroker
12
+ # or, once exported at top level:
13
+ from rabbitkit import AsyncBroker
14
+ """
15
+
16
+ import warnings
17
+
18
+ from rabbitkit.async_.broker import AsyncBroker
19
+ from rabbitkit.async_.transport import AsyncTransportImpl
20
+
21
+ warnings.warn(
22
+ "rabbitkit.aio is deprecated -- import from rabbitkit.async_ (or rabbitkit) instead. "
23
+ "rabbitkit.async_ is the canonical async import path.",
24
+ DeprecationWarning,
25
+ stacklevel=2,
26
+ )
27
+
28
+ __all__ = [
29
+ "AsyncBroker",
30
+ "AsyncTransportImpl",
31
+ ]
@@ -0,0 +1,9 @@
1
+ """Async transport module — aio-pika-based I/O adapter."""
2
+
3
+ from rabbitkit.async_.broker import AsyncBroker
4
+ from rabbitkit.async_.transport import AsyncTransportImpl
5
+
6
+ __all__ = [
7
+ "AsyncBroker",
8
+ "AsyncTransportImpl",
9
+ ]
@@ -0,0 +1,213 @@
1
+ """BatchPublisher — transparent batch publish with amortized confirm wait.
2
+
3
+ Collects messages from concurrent callers into fixed-size batches, then
4
+ publishes the whole batch on a single pooled channel. The broker-side confirm
5
+ wait is paid once per batch rather than once per message, dramatically reducing
6
+ the per-message cost at high concurrency.
7
+
8
+ Usage::
9
+
10
+ from rabbitkit.core.config import BatchPublishConfig
11
+
12
+ broker = AsyncBroker(
13
+ config,
14
+ batch_config=BatchPublishConfig(batch_size=64, flush_interval_ms=20),
15
+ )
16
+ await broker.start()
17
+ # broker.publish() is now transparently batched
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import contextlib
24
+ import logging
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from rabbitkit.core.config import BatchPublishConfig
28
+ from rabbitkit.core.types import MessageEnvelope, PublishOutcome, PublishStatus
29
+
30
+ if TYPE_CHECKING:
31
+ from rabbitkit.async_.transport import AsyncTransportImpl
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class AsyncBatchPublisher:
37
+ """Transparent batch-publish wrapper for AsyncTransportImpl.
38
+
39
+ N concurrent ``broker.publish()`` calls are coalesced into one batch,
40
+ published on a single channel, and their confirms are gathered together.
41
+ Reduces per-message pool acquire/release and amortizes confirm round-trips.
42
+
43
+ The caller's coroutine blocks until its message is included in a flushed
44
+ batch and the confirm resolves — semantics are identical to direct publish.
45
+
46
+ Unlike ``highload.batch.BatchPublisher`` (a timing/buffering helper that
47
+ publishes each message individually), this class pipelines confirms: all
48
+ messages in a batch share one channel and their ACKs are awaited together.
49
+
50
+ Blast-radius note (M17): because a batch shares one channel, an
51
+ AMQP-channel-level failure (e.g. a confirm timeout that closes the channel)
52
+ fails EVERY in-flight publish in that batch — each caller's future is
53
+ settled with the error (never silently lost), and the channel is replaced
54
+ before the next batch, but a single slow confirm can amplify into
55
+ batch-wide errors. This is inherent to sharing a channel for pipelined
56
+ confirms; there is no per-message isolation without giving up batching. To
57
+ bound the blast radius, lower ``BatchPublishConfig.batch_size`` (fewer
58
+ siblings share a channel) — or use direct ``broker.publish()`` for
59
+ publishes that must fail independently.
60
+ """
61
+
62
+ def __init__(self, transport: AsyncTransportImpl, config: BatchPublishConfig) -> None:
63
+ self._transport = transport
64
+ self._config = config
65
+ self._pending: asyncio.Queue[tuple[MessageEnvelope, asyncio.Future[PublishOutcome]]] = (
66
+ asyncio.Queue(maxsize=config.max_in_flight)
67
+ )
68
+ self._flush_tasks: list[asyncio.Task[None]] = []
69
+
70
+ def _worker_count(self) -> int:
71
+ if self._config.flush_workers > 0:
72
+ return self._config.flush_workers
73
+ return min(16, max(1, self._config.max_in_flight // self._config.batch_size))
74
+
75
+ async def start(self) -> None:
76
+ """Start N concurrent flush loops (one per channel slot)."""
77
+ n = self._worker_count()
78
+ self._flush_tasks = [
79
+ asyncio.create_task(self._flush_loop(), name=f"rabbitkit.batch-flush-{i}")
80
+ for i in range(n)
81
+ ]
82
+
83
+ async def stop(self) -> None:
84
+ """Cancel all flush loops and drain any remaining queued messages."""
85
+ for task in self._flush_tasks:
86
+ task.cancel()
87
+ for task in self._flush_tasks:
88
+ with contextlib.suppress(Exception, asyncio.CancelledError):
89
+ await task
90
+ self._flush_tasks = []
91
+ while not self._pending.empty():
92
+ try:
93
+ _, fut = self._pending.get_nowait()
94
+ if not fut.done():
95
+ fut.set_exception(RuntimeError("BatchPublisher stopped before flush"))
96
+ except asyncio.QueueEmpty: # pragma: no cover — TOCTOU guard, unreachable in asyncio
97
+ break
98
+
99
+ async def publish(self, envelope: MessageEnvelope) -> PublishOutcome:
100
+ """Enqueue *envelope* and wait for it to be included in a batch flush."""
101
+ loop = asyncio.get_running_loop()
102
+ fut: asyncio.Future[PublishOutcome] = loop.create_future()
103
+ await self._pending.put((envelope, fut))
104
+ return await fut
105
+
106
+ async def _flush_loop(self) -> None:
107
+ """Each worker holds one channel for its lifetime — no acquire/release per batch."""
108
+ interval = self._config.flush_interval_ms / 1000.0
109
+ channel: Any = None
110
+ batch: list[tuple[MessageEnvelope, asyncio.Future[PublishOutcome]]] = []
111
+ try:
112
+ channel = await self._transport._conn_pool.acquire_publisher_channel()
113
+ while True:
114
+ batch = []
115
+
116
+ # Block until the first item arrives
117
+ batch.append(await self._pending.get())
118
+
119
+ # Fast drain: grab all immediately-available items without yielding.
120
+ # At high concurrency the queue is almost always non-empty here, so
121
+ # this avoids the coroutine/timeout overhead of repeated wait_for calls.
122
+ while len(batch) < self._config.batch_size:
123
+ try:
124
+ batch.append(self._pending.get_nowait())
125
+ except asyncio.QueueEmpty:
126
+ break
127
+
128
+ # If still under batch_size, wait briefly for stragglers
129
+ if len(batch) < self._config.batch_size:
130
+ loop = asyncio.get_running_loop()
131
+ deadline = loop.time() + interval
132
+ while len(batch) < self._config.batch_size:
133
+ remaining = deadline - loop.time()
134
+ if remaining <= 0:
135
+ break
136
+ try:
137
+ async with asyncio.timeout(remaining):
138
+ batch.append(await self._pending.get())
139
+ except TimeoutError:
140
+ break
141
+
142
+ try:
143
+ await self._flush(channel, batch)
144
+ except BaseException as exc:
145
+ # Settle any futures _flush didn't resolve (including on
146
+ # CancelledError). Convert CancelledError to RuntimeError so
147
+ # it can be set on the Future (set_exception rejects BaseException).
148
+ err: BaseException = (
149
+ exc if isinstance(exc, Exception) else RuntimeError("Batch publisher cancelled")
150
+ )
151
+ for _, fut in batch:
152
+ if not fut.done():
153
+ fut.set_exception(err)
154
+ if isinstance(exc, Exception):
155
+ # Channel error — replace it. Set channel=None first so
156
+ # the finally block doesn't double-release if acquire fails.
157
+ old, channel = channel, None
158
+ with contextlib.suppress(Exception):
159
+ await self._transport._conn_pool.release_publisher_channel(old)
160
+ channel = await self._transport._conn_pool.acquire_publisher_channel()
161
+ else:
162
+ raise # CancelledError must propagate
163
+ else:
164
+ # _publish_on_channel closes the channel on confirm timeout;
165
+ # detect that and replace it before the next batch.
166
+ if channel.is_closed:
167
+ old, channel = channel, None
168
+ with contextlib.suppress(Exception):
169
+ await self._transport._conn_pool.release_publisher_channel(old)
170
+ channel = await self._transport._conn_pool.acquire_publisher_channel()
171
+ except BaseException:
172
+ # Any exception escaping the loop (e.g. CancelledError at pending.get()
173
+ # or straggler wait) — settle any dequeued-but-unresolved futures.
174
+ for _, fut in batch:
175
+ if not fut.done():
176
+ fut.set_exception(RuntimeError("Batch publisher cancelled"))
177
+ raise
178
+ finally:
179
+ if channel is not None:
180
+ with contextlib.suppress(Exception):
181
+ await self._transport._conn_pool.release_publisher_channel(channel)
182
+
183
+ async def _flush(
184
+ self,
185
+ channel: Any,
186
+ batch: list[tuple[MessageEnvelope, asyncio.Future[PublishOutcome]]],
187
+ ) -> None:
188
+ """Publish all envelopes on the worker's persistent channel and resolve futures."""
189
+ outcomes: list[Any] = await asyncio.gather(
190
+ *[self._transport._publish_on_channel(channel, env) for env, _ in batch],
191
+ return_exceptions=True,
192
+ )
193
+ for (_, fut), outcome in zip(batch, outcomes, strict=False):
194
+ if not fut.done():
195
+ if isinstance(outcome, BaseException):
196
+ fut.set_exception(outcome)
197
+ else:
198
+ fut.set_result(outcome)
199
+
200
+ # M17: _publish_on_channel no longer closes the channel itself on a
201
+ # confirm timeout (that used to fire from INSIDE one of the concurrent
202
+ # calls above, corrupting sibling in-flight confirms sharing this same
203
+ # channel). Instead, close it HERE — every concurrent publish in this
204
+ # batch has already resolved by this point, so closing can no longer
205
+ # amplify this batch's failure onto a message that would have
206
+ # confirmed fine. The flush loop's post-_flush check (channel.is_closed)
207
+ # still replaces it for the next batch, preserving the original
208
+ # "don't reuse a wedged channel" protection.
209
+ if not channel.is_closed and any(
210
+ not isinstance(o, BaseException) and o.status == PublishStatus.TIMEOUT for o in outcomes
211
+ ):
212
+ with contextlib.suppress(Exception):
213
+ await channel.close()