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.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
rabbitkit/management.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""RabbitMQ Management HTTP API client.
|
|
2
|
+
|
|
3
|
+
Provides a thin Python wrapper around the **RabbitMQ Management HTTP API**
|
|
4
|
+
(available on port 15672 by default).
|
|
5
|
+
|
|
6
|
+
* **Sync** operations use ``urllib.request`` — no extra dependencies.
|
|
7
|
+
* **Async** operations use ``aiohttp`` — requires ``pip install rabbitkit[management]``.
|
|
8
|
+
|
|
9
|
+
Security
|
|
10
|
+
--------
|
|
11
|
+
* Schemes other than ``http``/``https`` are rejected at construction
|
|
12
|
+
(prevents ``file://``/``gopher://`` SSRF).
|
|
13
|
+
* Redirects are **not** followed (sync and async) — a 3xx response is surfaced
|
|
14
|
+
as an error so a crafted ``Location`` header cannot redirect the client to an
|
|
15
|
+
internal host.
|
|
16
|
+
* Response bodies are capped (``_MAX_RESPONSE_BYTES``) to guard against
|
|
17
|
+
runaway / zip-bomb responses.
|
|
18
|
+
|
|
19
|
+
Quick start
|
|
20
|
+
-----------
|
|
21
|
+
from rabbitkit.management import RabbitManagementClient, ManagementConfig
|
|
22
|
+
|
|
23
|
+
client = RabbitManagementClient(
|
|
24
|
+
ManagementConfig(
|
|
25
|
+
url="http://rabbitmq:15672",
|
|
26
|
+
username="admin",
|
|
27
|
+
password="secret",
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# List all queues in the default vhost
|
|
32
|
+
for q in client.list_queues():
|
|
33
|
+
print(q["name"], q["messages"])
|
|
34
|
+
|
|
35
|
+
# Purge a queue
|
|
36
|
+
client.purge_queue("orders", vhost="/production")
|
|
37
|
+
|
|
38
|
+
# Check if the node is healthy
|
|
39
|
+
if not client.health_check():
|
|
40
|
+
alert("RabbitMQ node is unhealthy!")
|
|
41
|
+
|
|
42
|
+
Async usage (requires aiohttp)
|
|
43
|
+
-------------------------------
|
|
44
|
+
async with AsyncBroker(...) as broker:
|
|
45
|
+
client = RabbitManagementClient()
|
|
46
|
+
queues = await client.list_queues_async()
|
|
47
|
+
print(queues)
|
|
48
|
+
|
|
49
|
+
Available operations
|
|
50
|
+
--------------------
|
|
51
|
+
Sync:
|
|
52
|
+
|
|
53
|
+
client.list_queues(vhost="/") -> list[QueueInfo]
|
|
54
|
+
client.get_queue(name, vhost="/") -> QueueInfo
|
|
55
|
+
client.purge_queue(name, vhost="/") -> None
|
|
56
|
+
client.delete_queue(name, vhost="/") -> None
|
|
57
|
+
client.declare_queue(name, vhost="/", durable=True, arguments=None) -> None
|
|
58
|
+
client.get_queue_bindings(queue, vhost="/") -> list[dict]
|
|
59
|
+
client.bind_queue(queue, exchange, routing_key="", vhost="/", arguments=None) -> None
|
|
60
|
+
client.put_parameter(component, vhost, name, value) -> None
|
|
61
|
+
client.delete_parameter(component, vhost, name) -> None
|
|
62
|
+
client.list_shovel_statuses() -> list[dict]
|
|
63
|
+
client.list_exchanges(vhost="/") -> list[ExchangeInfo]
|
|
64
|
+
client.get_exchange(name, vhost="/") -> ExchangeInfo
|
|
65
|
+
client.list_connections() -> list[ConnectionInfo]
|
|
66
|
+
client.list_channels() -> list[ChannelInfo]
|
|
67
|
+
client.overview() -> OverviewInfo
|
|
68
|
+
client.health_check() -> bool
|
|
69
|
+
|
|
70
|
+
Async (all have ``_async`` suffix):
|
|
71
|
+
|
|
72
|
+
await client.list_queues_async()
|
|
73
|
+
await client.get_queue_async(name)
|
|
74
|
+
await client.overview_async()
|
|
75
|
+
await client.health_check_async()
|
|
76
|
+
|
|
77
|
+
Dashboard integration
|
|
78
|
+
---------------------
|
|
79
|
+
Pass a ``RabbitManagementClient`` instance to ``create_dashboard_app()`` to
|
|
80
|
+
enrich the monitoring dashboard with live queue stats:
|
|
81
|
+
|
|
82
|
+
from rabbitkit.management import RabbitManagementClient
|
|
83
|
+
from rabbitkit.dashboard import create_dashboard_app
|
|
84
|
+
|
|
85
|
+
mgmt = RabbitManagementClient()
|
|
86
|
+
app = create_dashboard_app(broker, management_client=mgmt)
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
from __future__ import annotations
|
|
90
|
+
|
|
91
|
+
import base64
|
|
92
|
+
import json
|
|
93
|
+
import urllib.error
|
|
94
|
+
import urllib.parse
|
|
95
|
+
import urllib.request
|
|
96
|
+
import warnings
|
|
97
|
+
from dataclasses import dataclass
|
|
98
|
+
from typing import Any, TypedDict, cast
|
|
99
|
+
|
|
100
|
+
# Hard cap on a single Management API response body (zip-bomb / runaway guard).
|
|
101
|
+
_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
102
|
+
# Read in chunks of this size when enforcing the response cap.
|
|
103
|
+
_READ_CHUNK = 64 * 1024
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class QueueInfo(TypedDict, total=False):
|
|
107
|
+
"""Typed view of a RabbitMQ Management API queue response.
|
|
108
|
+
|
|
109
|
+
The Management API returns many fields; this captures the most common
|
|
110
|
+
ones. ``total=False`` means every key is optional — the API may omit
|
|
111
|
+
fields depending on the endpoint and RabbitMQ version.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
vhost: str
|
|
116
|
+
type: str
|
|
117
|
+
durable: bool
|
|
118
|
+
auto_delete: bool
|
|
119
|
+
messages: int
|
|
120
|
+
messages_ready: int
|
|
121
|
+
messages_unacknowledged: int
|
|
122
|
+
consumers: int
|
|
123
|
+
state: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ExchangeInfo(TypedDict, total=False):
|
|
127
|
+
"""Typed view of a RabbitMQ Management API exchange response."""
|
|
128
|
+
|
|
129
|
+
name: str
|
|
130
|
+
type: str
|
|
131
|
+
durable: bool
|
|
132
|
+
auto_delete: bool
|
|
133
|
+
internal: bool
|
|
134
|
+
vhost: str
|
|
135
|
+
arguments: dict[str, Any]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ConnectionInfo(TypedDict, total=False):
|
|
139
|
+
"""Typed view of a RabbitMQ Management API connection response."""
|
|
140
|
+
|
|
141
|
+
name: str
|
|
142
|
+
vhost: str
|
|
143
|
+
user: str
|
|
144
|
+
protocol: str
|
|
145
|
+
state: str
|
|
146
|
+
channels: int
|
|
147
|
+
peer_host: str
|
|
148
|
+
peer_port: int
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ChannelInfo(TypedDict, total=False):
|
|
152
|
+
"""Typed view of a RabbitMQ Management API channel response."""
|
|
153
|
+
|
|
154
|
+
number: int
|
|
155
|
+
user: str
|
|
156
|
+
vhost: str
|
|
157
|
+
connection_name: str
|
|
158
|
+
state: str
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class OverviewInfo(TypedDict, total=False):
|
|
162
|
+
"""Typed view of the RabbitMQ Management API ``/overview`` response."""
|
|
163
|
+
|
|
164
|
+
rabbitmq_version: str
|
|
165
|
+
erlang_version: str
|
|
166
|
+
cluster_name: str
|
|
167
|
+
contexts: list[dict[str, Any]]
|
|
168
|
+
listeners: list[dict[str, Any]]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
172
|
+
"""Reject all redirects — return ``None`` so urllib raises ``HTTPError``.
|
|
173
|
+
|
|
174
|
+
With this handler installed, a 3xx response surfaces as ``HTTPError`` (no
|
|
175
|
+
handler claims it), which the caller catches and turns into a ``ValueError``
|
|
176
|
+
— preventing silent SSRF via a ``Location`` header pointing at an internal host.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> urllib.request.Request | None:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class ManagementConfig:
|
|
185
|
+
"""RabbitMQ Management API configuration.
|
|
186
|
+
|
|
187
|
+
.. warning::
|
|
188
|
+
The default URL uses ``http://`` with ``guest``/``guest`` credentials —
|
|
189
|
+
this is intended **only for local development**. In production use
|
|
190
|
+
``https://`` with non-default credentials. Schemes other than
|
|
191
|
+
``http``/``https`` are rejected to prevent SSRF via crafted URLs.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
url: str = "http://localhost:15672"
|
|
195
|
+
username: str = "guest"
|
|
196
|
+
password: str = "guest"
|
|
197
|
+
timeout: float = 10.0
|
|
198
|
+
|
|
199
|
+
def __repr__(self) -> str:
|
|
200
|
+
"""L2: mask the password — the default dataclass repr would leak it
|
|
201
|
+
in plaintext into any log line or traceback that reprs this object."""
|
|
202
|
+
from rabbitkit.core.config import _masked_repr
|
|
203
|
+
|
|
204
|
+
return _masked_repr(self)
|
|
205
|
+
|
|
206
|
+
def __post_init__(self) -> None:
|
|
207
|
+
scheme = urllib.parse.urlparse(self.url).scheme.lower()
|
|
208
|
+
if scheme not in {"http", "https"}:
|
|
209
|
+
raise ValueError(f"Unsupported management URL scheme {scheme!r}; only 'http' and 'https' are allowed.")
|
|
210
|
+
# Mirror ConnectionConfig.__post_init__: warn when the default 'guest'
|
|
211
|
+
# credentials are used against a non-local host (dev convenience, but
|
|
212
|
+
# flag the production misconfiguration once at construction).
|
|
213
|
+
hostname = urllib.parse.urlparse(self.url).hostname
|
|
214
|
+
if self.username == "guest" and hostname not in {"localhost", "127.0.0.1", "::1", None}:
|
|
215
|
+
warnings.warn(
|
|
216
|
+
"ManagementConfig uses default 'guest' credentials against non-local "
|
|
217
|
+
f"host {hostname!r}; set explicit username/password for production.",
|
|
218
|
+
UserWarning,
|
|
219
|
+
stacklevel=2,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class RabbitManagementClient:
|
|
224
|
+
"""HTTP client for the RabbitMQ Management API."""
|
|
225
|
+
|
|
226
|
+
def __init__(self, config: ManagementConfig | None = None) -> None:
|
|
227
|
+
self._config = config or ManagementConfig()
|
|
228
|
+
credentials = f"{self._config.username}:{self._config.password}"
|
|
229
|
+
self._auth_header = "Basic " + base64.b64encode(credentials.encode()).decode()
|
|
230
|
+
# No-redirect opener: 3xx raise HTTPError instead of being followed.
|
|
231
|
+
self._opener = urllib.request.build_opener(_NoRedirect)
|
|
232
|
+
# Long-lived aiohttp session reused across *_async requests (L-6).
|
|
233
|
+
# Lazily created on the first async request; closed via aclose().
|
|
234
|
+
self._aiohttp_session: Any = None
|
|
235
|
+
|
|
236
|
+
def _request(self, method: str, path: str, body: bytes | None = None) -> Any:
|
|
237
|
+
url = f"{self._config.url}/api{path}"
|
|
238
|
+
req = urllib.request.Request(url, method=method, data=body) # noqa: S310
|
|
239
|
+
req.add_header("Authorization", self._auth_header)
|
|
240
|
+
req.add_header("Content-Type", "application/json")
|
|
241
|
+
try:
|
|
242
|
+
resp = self._opener.open(req, timeout=self._config.timeout)
|
|
243
|
+
except urllib.error.HTTPError as exc:
|
|
244
|
+
# With _NoRedirect installed, 3xx surface here as HTTPError.
|
|
245
|
+
if 300 <= exc.code < 400:
|
|
246
|
+
raise ValueError(f"Unexpected redirect ({exc.code}) to {exc.headers.get('Location')}") from exc
|
|
247
|
+
raise
|
|
248
|
+
with resp:
|
|
249
|
+
if resp.status == 204:
|
|
250
|
+
# Drain + discard any (usually empty) body.
|
|
251
|
+
return None
|
|
252
|
+
data = self._read_capped(resp)
|
|
253
|
+
if not data:
|
|
254
|
+
# e.g. 201 Created from PUT /queues, /parameters, /bindings — empty body.
|
|
255
|
+
return None
|
|
256
|
+
return json.loads(data.decode())
|
|
257
|
+
|
|
258
|
+
def _read_capped(self, resp: Any) -> bytes:
|
|
259
|
+
"""Read the response body in chunks, raising if it exceeds the cap."""
|
|
260
|
+
total = 0
|
|
261
|
+
chunks: list[bytes] = []
|
|
262
|
+
while True:
|
|
263
|
+
chunk = resp.read(_READ_CHUNK)
|
|
264
|
+
if not chunk:
|
|
265
|
+
break
|
|
266
|
+
total += len(chunk)
|
|
267
|
+
if total > _MAX_RESPONSE_BYTES:
|
|
268
|
+
raise ValueError(f"Management API response exceeded {_MAX_RESPONSE_BYTES} bytes")
|
|
269
|
+
chunks.append(chunk)
|
|
270
|
+
return b"".join(chunks)
|
|
271
|
+
|
|
272
|
+
# Queue operations
|
|
273
|
+
def list_queues(self, vhost: str = "/") -> list[QueueInfo]:
|
|
274
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
275
|
+
return cast("list[QueueInfo]", self._request("GET", f"/queues/{vhost_encoded}"))
|
|
276
|
+
|
|
277
|
+
def get_queue(self, name: str, vhost: str = "/") -> QueueInfo:
|
|
278
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
279
|
+
name_encoded = urllib.parse.quote(name, safe="")
|
|
280
|
+
return cast("QueueInfo", self._request("GET", f"/queues/{vhost_encoded}/{name_encoded}"))
|
|
281
|
+
|
|
282
|
+
def purge_queue(self, name: str, vhost: str = "/") -> None:
|
|
283
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
284
|
+
name_encoded = urllib.parse.quote(name, safe="")
|
|
285
|
+
self._request("DELETE", f"/queues/{vhost_encoded}/{name_encoded}/contents")
|
|
286
|
+
|
|
287
|
+
def delete_queue(self, name: str, vhost: str = "/") -> None:
|
|
288
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
289
|
+
name_encoded = urllib.parse.quote(name, safe="")
|
|
290
|
+
self._request("DELETE", f"/queues/{vhost_encoded}/{name_encoded}")
|
|
291
|
+
|
|
292
|
+
# Exchange operations
|
|
293
|
+
def list_exchanges(self, vhost: str = "/") -> list[ExchangeInfo]:
|
|
294
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
295
|
+
return cast("list[ExchangeInfo]", self._request("GET", f"/exchanges/{vhost_encoded}"))
|
|
296
|
+
|
|
297
|
+
def get_exchange(self, name: str, vhost: str = "/") -> ExchangeInfo:
|
|
298
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
299
|
+
name_encoded = urllib.parse.quote(name, safe="")
|
|
300
|
+
return cast("ExchangeInfo", self._request("GET", f"/exchanges/{vhost_encoded}/{name_encoded}"))
|
|
301
|
+
|
|
302
|
+
# Queue declaration / bindings (used by `rabbitkit topology migrate`)
|
|
303
|
+
def declare_queue(
|
|
304
|
+
self,
|
|
305
|
+
name: str,
|
|
306
|
+
vhost: str = "/",
|
|
307
|
+
durable: bool = True,
|
|
308
|
+
arguments: dict[str, Any] | None = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Declare a queue via ``PUT /api/queues/{vhost}/{name}``.
|
|
311
|
+
|
|
312
|
+
``arguments`` are the queue's x-arguments (e.g. ``{"x-queue-type": "quorum"}``).
|
|
313
|
+
"""
|
|
314
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
315
|
+
name_encoded = urllib.parse.quote(name, safe="")
|
|
316
|
+
body = json.dumps({"durable": durable, "auto_delete": False, "arguments": arguments or {}}).encode()
|
|
317
|
+
self._request("PUT", f"/queues/{vhost_encoded}/{name_encoded}", body)
|
|
318
|
+
|
|
319
|
+
def get_queue_bindings(self, queue: str, vhost: str = "/") -> list[dict[str, Any]]:
|
|
320
|
+
"""List all bindings of a queue via ``GET /api/queues/{vhost}/{queue}/bindings``."""
|
|
321
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
322
|
+
queue_encoded = urllib.parse.quote(queue, safe="")
|
|
323
|
+
return cast(
|
|
324
|
+
"list[dict[str, Any]]",
|
|
325
|
+
self._request("GET", f"/queues/{vhost_encoded}/{queue_encoded}/bindings"),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def bind_queue(
|
|
329
|
+
self,
|
|
330
|
+
queue: str,
|
|
331
|
+
exchange: str,
|
|
332
|
+
routing_key: str = "",
|
|
333
|
+
vhost: str = "/",
|
|
334
|
+
arguments: dict[str, Any] | None = None,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Bind a queue to an exchange via ``POST /api/bindings/{vhost}/e/{exchange}/q/{queue}``."""
|
|
337
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
338
|
+
exchange_encoded = urllib.parse.quote(exchange, safe="")
|
|
339
|
+
queue_encoded = urllib.parse.quote(queue, safe="")
|
|
340
|
+
body = json.dumps({"routing_key": routing_key, "arguments": arguments or {}}).encode()
|
|
341
|
+
self._request("POST", f"/bindings/{vhost_encoded}/e/{exchange_encoded}/q/{queue_encoded}", body)
|
|
342
|
+
|
|
343
|
+
# Runtime parameters (dynamic shovels, federation upstreams, ...)
|
|
344
|
+
def put_parameter(self, component: str, vhost: str, name: str, value: dict[str, Any]) -> None:
|
|
345
|
+
"""Create/update a runtime parameter via ``PUT /api/parameters/{component}/{vhost}/{name}``.
|
|
346
|
+
|
|
347
|
+
For a dynamic shovel: ``put_parameter("shovel", "/", "my-shovel", {"value": {...}})``.
|
|
348
|
+
"""
|
|
349
|
+
component_encoded = urllib.parse.quote(component, safe="")
|
|
350
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
351
|
+
name_encoded = urllib.parse.quote(name, safe="")
|
|
352
|
+
body = json.dumps(value).encode()
|
|
353
|
+
self._request("PUT", f"/parameters/{component_encoded}/{vhost_encoded}/{name_encoded}", body)
|
|
354
|
+
|
|
355
|
+
def delete_parameter(self, component: str, vhost: str, name: str) -> None:
|
|
356
|
+
"""Delete a runtime parameter via ``DELETE /api/parameters/{component}/{vhost}/{name}``."""
|
|
357
|
+
component_encoded = urllib.parse.quote(component, safe="")
|
|
358
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
359
|
+
name_encoded = urllib.parse.quote(name, safe="")
|
|
360
|
+
self._request("DELETE", f"/parameters/{component_encoded}/{vhost_encoded}/{name_encoded}")
|
|
361
|
+
|
|
362
|
+
def list_shovel_statuses(self) -> list[dict[str, Any]]:
|
|
363
|
+
"""List shovel statuses via ``GET /api/shovels``.
|
|
364
|
+
|
|
365
|
+
Raises (HTTP 404) when the ``rabbitmq_shovel`` plugin is not enabled.
|
|
366
|
+
"""
|
|
367
|
+
return cast("list[dict[str, Any]]", self._request("GET", "/shovels"))
|
|
368
|
+
|
|
369
|
+
# Connection/Channel
|
|
370
|
+
def list_connections(self) -> list[ConnectionInfo]:
|
|
371
|
+
return cast("list[ConnectionInfo]", self._request("GET", "/connections"))
|
|
372
|
+
|
|
373
|
+
def list_channels(self) -> list[ChannelInfo]:
|
|
374
|
+
return cast("list[ChannelInfo]", self._request("GET", "/channels"))
|
|
375
|
+
|
|
376
|
+
# Overview
|
|
377
|
+
def overview(self) -> OverviewInfo:
|
|
378
|
+
return cast("OverviewInfo", self._request("GET", "/overview"))
|
|
379
|
+
|
|
380
|
+
def health_check(self) -> bool:
|
|
381
|
+
try:
|
|
382
|
+
result = self._request("GET", "/healthchecks/node")
|
|
383
|
+
return bool(result.get("status") == "ok")
|
|
384
|
+
except Exception:
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
# Async variants
|
|
388
|
+
async def list_queues_async(self, vhost: str = "/") -> list[QueueInfo]:
|
|
389
|
+
vhost_encoded = urllib.parse.quote(vhost, safe="")
|
|
390
|
+
return cast("list[QueueInfo]", await self._request_async("GET", f"/queues/{vhost_encoded}"))
|
|
391
|
+
|
|
392
|
+
async def get_queue_async(self, name: str, vhost: str = "/") -> QueueInfo:
|
|
393
|
+
v = urllib.parse.quote(vhost, safe="")
|
|
394
|
+
n = urllib.parse.quote(name, safe="")
|
|
395
|
+
return cast("QueueInfo", await self._request_async("GET", f"/queues/{v}/{n}"))
|
|
396
|
+
|
|
397
|
+
async def overview_async(self) -> OverviewInfo:
|
|
398
|
+
return cast("OverviewInfo", await self._request_async("GET", "/overview"))
|
|
399
|
+
|
|
400
|
+
async def health_check_async(self) -> bool:
|
|
401
|
+
try:
|
|
402
|
+
result = await self._request_async("GET", "/healthchecks/node")
|
|
403
|
+
return bool(result.get("status") == "ok")
|
|
404
|
+
except Exception:
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
async def _read_capped_async(self, resp: Any) -> bytes:
|
|
408
|
+
"""Read the async response body in chunks, raising if it exceeds the cap."""
|
|
409
|
+
total = 0
|
|
410
|
+
chunks: list[bytes] = []
|
|
411
|
+
while True:
|
|
412
|
+
chunk = await resp.content.read(_READ_CHUNK)
|
|
413
|
+
if not chunk:
|
|
414
|
+
break
|
|
415
|
+
total += len(chunk)
|
|
416
|
+
if total > _MAX_RESPONSE_BYTES:
|
|
417
|
+
raise ValueError(f"Management API response exceeded {_MAX_RESPONSE_BYTES} bytes")
|
|
418
|
+
chunks.append(chunk)
|
|
419
|
+
return b"".join(chunks)
|
|
420
|
+
|
|
421
|
+
async def _get_session(self) -> Any:
|
|
422
|
+
"""Lazily create and reuse a single aiohttp.ClientSession (L-6)."""
|
|
423
|
+
try:
|
|
424
|
+
import aiohttp
|
|
425
|
+
except ImportError:
|
|
426
|
+
raise ImportError(
|
|
427
|
+
"Async management API requires aiohttp. Install with: pip install rabbitkit[management]"
|
|
428
|
+
) from None
|
|
429
|
+
if self._aiohttp_session is None:
|
|
430
|
+
self._aiohttp_session = aiohttp.ClientSession()
|
|
431
|
+
return self._aiohttp_session
|
|
432
|
+
|
|
433
|
+
async def aclose(self) -> None:
|
|
434
|
+
"""Close the long-lived aiohttp session if one was created.
|
|
435
|
+
|
|
436
|
+
``*_async`` methods reuse a single ``aiohttp.ClientSession`` for
|
|
437
|
+
connection pooling. Call ``aclose()`` at shutdown to release the
|
|
438
|
+
underlying connector's sockets; otherwise the session is cleaned up
|
|
439
|
+
when the event loop closes.
|
|
440
|
+
"""
|
|
441
|
+
if self._aiohttp_session is not None:
|
|
442
|
+
await self._aiohttp_session.close()
|
|
443
|
+
self._aiohttp_session = None
|
|
444
|
+
|
|
445
|
+
async def _request_async(self, method: str, path: str, body: bytes | None = None) -> Any:
|
|
446
|
+
try:
|
|
447
|
+
import aiohttp
|
|
448
|
+
except ImportError:
|
|
449
|
+
raise ImportError(
|
|
450
|
+
"Async management API requires aiohttp. Install with: pip install rabbitkit[management]"
|
|
451
|
+
) from None
|
|
452
|
+
session = await self._get_session()
|
|
453
|
+
|
|
454
|
+
url = f"{self._config.url}/api{path}"
|
|
455
|
+
headers = {"Authorization": self._auth_header, "Content-Type": "application/json"}
|
|
456
|
+
async with session.request(
|
|
457
|
+
method,
|
|
458
|
+
url,
|
|
459
|
+
headers=headers,
|
|
460
|
+
data=body,
|
|
461
|
+
timeout=aiohttp.ClientTimeout(total=self._config.timeout),
|
|
462
|
+
allow_redirects=False, # never follow — surface 3xx as an error (SSRF guard)
|
|
463
|
+
) as resp:
|
|
464
|
+
if 300 <= resp.status < 400:
|
|
465
|
+
raise ValueError(f"Unexpected redirect ({resp.status}) to {resp.headers.get('Location')}")
|
|
466
|
+
if resp.status == 204:
|
|
467
|
+
return None
|
|
468
|
+
if not (200 <= resp.status < 300):
|
|
469
|
+
resp.raise_for_status() # match the sync path: raise on non-2xx
|
|
470
|
+
return json.loads((await self._read_capped_async(resp)).decode())
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Middleware module — composable consume/publish processing."""
|
|
2
|
+
|
|
3
|
+
from rabbitkit.middleware.circuit_breaker import CircuitBreakerMiddleware, CircuitBreakerOpenError
|
|
4
|
+
from rabbitkit.middleware.deduplication import DeduplicationMiddleware
|
|
5
|
+
from rabbitkit.middleware.metrics import MetricsCollector, MetricsMiddleware, PrometheusCollector
|
|
6
|
+
from rabbitkit.middleware.otel import OTelTracingMiddleware
|
|
7
|
+
from rabbitkit.middleware.rate_limit import RateLimitConfig, RateLimitMiddleware
|
|
8
|
+
from rabbitkit.middleware.signing import InvalidSignatureError, SigningConfig, SigningMiddleware
|
|
9
|
+
from rabbitkit.middleware.timeout import HandlerTimeoutError, TimeoutConfig, TimeoutMiddleware
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CircuitBreakerMiddleware",
|
|
13
|
+
"CircuitBreakerOpenError",
|
|
14
|
+
"DeduplicationMiddleware",
|
|
15
|
+
"HandlerTimeoutError",
|
|
16
|
+
"InvalidSignatureError",
|
|
17
|
+
"MetricsCollector",
|
|
18
|
+
"MetricsMiddleware",
|
|
19
|
+
"OTelTracingMiddleware",
|
|
20
|
+
"PrometheusCollector",
|
|
21
|
+
"RateLimitConfig",
|
|
22
|
+
"RateLimitMiddleware",
|
|
23
|
+
"SigningConfig",
|
|
24
|
+
"SigningMiddleware",
|
|
25
|
+
"TimeoutConfig",
|
|
26
|
+
"TimeoutMiddleware",
|
|
27
|
+
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Base middleware protocol — lifecycle hooks for message processing.
|
|
2
|
+
|
|
3
|
+
Both sync and async variants provided.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rabbitkit.core.message import RabbitMessage
|
|
12
|
+
from rabbitkit.core.types import MessageEnvelope
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseMiddleware:
|
|
16
|
+
"""Base middleware with lifecycle hooks.
|
|
17
|
+
|
|
18
|
+
Hooks:
|
|
19
|
+
- on_receive(msg): notification when message is received (before processing)
|
|
20
|
+
- consume_scope(call_next, msg): wrap handler execution
|
|
21
|
+
- after_processed(msg, exc): post-processing notification
|
|
22
|
+
- publish_scope(call_next, envelope): wrap outgoing publish
|
|
23
|
+
|
|
24
|
+
All hooks have no-op defaults. Subclasses override what they need.
|
|
25
|
+
Both sync and async variants provided.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# ── Consume-side hooks ───────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def on_receive(self, message: RabbitMessage) -> None:
|
|
31
|
+
"""Called when a message is received, before processing.
|
|
32
|
+
|
|
33
|
+
H7 — two things every on_receive override must account for:
|
|
34
|
+
|
|
35
|
+
1. Runs in a fixed, flat pre-pass entirely BEFORE consume_scope is
|
|
36
|
+
entered for ANY middleware on the route. An exception raised here
|
|
37
|
+
is NOT caught by any middleware's consume_scope — not even
|
|
38
|
+
RetryMiddleware's — so it is never retry-eligible via the
|
|
39
|
+
delay-queue mechanism; it settles per the route's AckPolicy using
|
|
40
|
+
the pipeline's default classifier instead. If your check should be
|
|
41
|
+
retryable, implement it in consume_scope, not here.
|
|
42
|
+
2. Runs in REVERSE registration order — the mirror of publish_scope's
|
|
43
|
+
outer→inner composition — so a receive-side transform that undoes
|
|
44
|
+
a publish-side one (e.g. decompress undoing compress) runs in the
|
|
45
|
+
mathematically correct relative order. This does NOT mean any two
|
|
46
|
+
on_receive-based middlewares can be listed in either order and
|
|
47
|
+
both work — e.g. SigningMiddleware + CompressionMiddleware only
|
|
48
|
+
work as ``[CompressionMiddleware, SigningMiddleware]`` (compression
|
|
49
|
+
outer), because the signature covers content_encoding, a field
|
|
50
|
+
compression itself sets. See HandlerPipeline._run_consume_sync's
|
|
51
|
+
docstring for the full explanation.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
async def on_receive_async(self, message: RabbitMessage) -> None:
|
|
55
|
+
"""Async variant of on_receive."""
|
|
56
|
+
self.on_receive(message)
|
|
57
|
+
|
|
58
|
+
def consume_scope(
|
|
59
|
+
self,
|
|
60
|
+
call_next: Callable[[RabbitMessage], Any],
|
|
61
|
+
message: RabbitMessage,
|
|
62
|
+
) -> Any:
|
|
63
|
+
"""Wrap handler execution (sync). Must call call_next(message)."""
|
|
64
|
+
return call_next(message)
|
|
65
|
+
|
|
66
|
+
async def consume_scope_async(
|
|
67
|
+
self,
|
|
68
|
+
call_next: Callable[[RabbitMessage], Awaitable[Any]],
|
|
69
|
+
message: RabbitMessage,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Wrap handler execution (async). Must call await call_next(message)."""
|
|
72
|
+
return await call_next(message)
|
|
73
|
+
|
|
74
|
+
def after_processed(
|
|
75
|
+
self,
|
|
76
|
+
message: RabbitMessage,
|
|
77
|
+
exc: BaseException | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Called after message processing completes (success or failure)."""
|
|
80
|
+
|
|
81
|
+
async def after_processed_async(
|
|
82
|
+
self,
|
|
83
|
+
message: RabbitMessage,
|
|
84
|
+
exc: BaseException | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Async variant of after_processed."""
|
|
87
|
+
self.after_processed(message, exc)
|
|
88
|
+
|
|
89
|
+
# ── Publish-side hooks ───────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def publish_scope(
|
|
92
|
+
self,
|
|
93
|
+
call_next: Callable[[MessageEnvelope], Any],
|
|
94
|
+
envelope: MessageEnvelope,
|
|
95
|
+
) -> Any:
|
|
96
|
+
"""Wrap outgoing publish (sync). Must call call_next(envelope)."""
|
|
97
|
+
return call_next(envelope)
|
|
98
|
+
|
|
99
|
+
async def publish_scope_async(
|
|
100
|
+
self,
|
|
101
|
+
call_next: Callable[[MessageEnvelope], Awaitable[Any]],
|
|
102
|
+
envelope: MessageEnvelope,
|
|
103
|
+
) -> Any:
|
|
104
|
+
"""Wrap outgoing publish (async). Must call await call_next(envelope)."""
|
|
105
|
+
return await call_next(envelope)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class NoOpMiddleware(BaseMiddleware):
|
|
109
|
+
"""Null Object middleware — zero-overhead pass-through.
|
|
110
|
+
|
|
111
|
+
Use as a default when no middleware is configured, eliminating
|
|
112
|
+
``if collector is None: return call_next(...)`` branches on the hot path.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def consume_scope(self, call_next: Any, message: Any) -> Any:
|
|
116
|
+
return call_next(message)
|
|
117
|
+
|
|
118
|
+
async def consume_scope_async(self, call_next: Any, message: Any) -> Any:
|
|
119
|
+
return await call_next(message)
|
|
120
|
+
|
|
121
|
+
def publish_scope(self, call_next: Any, envelope: Any) -> Any:
|
|
122
|
+
return call_next(envelope)
|
|
123
|
+
|
|
124
|
+
async def publish_scope_async(self, call_next: Any, envelope: Any) -> Any:
|
|
125
|
+
return await call_next(envelope)
|