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,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)