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,105 @@
1
+ """ExceptionMiddleware — outermost middleware for exception handling.
2
+
3
+ Catches exceptions AFTER retry gives up.
4
+ Provides fallback values for error recovery.
5
+
6
+ See Contract 1 for terminal vs non-terminal behavior.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import Awaitable, Callable
13
+ from typing import Any
14
+
15
+ from rabbitkit.core.message import RabbitMessage
16
+ from rabbitkit.middleware.base import BaseMiddleware
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ExceptionMiddleware(BaseMiddleware):
22
+ """Outermost middleware. Catches exceptions after retry gives up.
23
+
24
+ Features:
25
+ - Register exception handlers with fallback values
26
+ - Terminal exceptions (from retry exhaustion) are re-raised by default
27
+ - swallow_permanent=True opts in to swallowing permanent/exhausted failures
28
+
29
+ MANUAL mode restriction:
30
+ - MAY log the error
31
+ - MUST NOT auto-publish fallback to result_publisher unless msg.is_settled
32
+ - MUST NOT settle the message
33
+ """
34
+
35
+ def __init__(self, *, swallow_permanent: bool = False) -> None:
36
+ self._handlers: dict[type[BaseException], Callable[[BaseException], Any]] = {}
37
+ self._swallow_permanent = swallow_permanent
38
+
39
+ def add_handler(
40
+ self,
41
+ exc_type: type[BaseException],
42
+ handler: Callable[[BaseException], Any],
43
+ ) -> None:
44
+ """Register an exception handler with a fallback return value."""
45
+ self._handlers[exc_type] = handler
46
+
47
+ def consume_scope(
48
+ self,
49
+ call_next: Callable[[RabbitMessage], Any],
50
+ message: RabbitMessage,
51
+ ) -> Any:
52
+ """Wrap handler — catch exceptions, provide fallback values."""
53
+ try:
54
+ return call_next(message)
55
+ except Exception as exc:
56
+ return self._handle_exception(exc, message)
57
+
58
+ async def consume_scope_async(
59
+ self,
60
+ call_next: Callable[[RabbitMessage], Awaitable[Any]],
61
+ message: RabbitMessage,
62
+ ) -> Any:
63
+ """Async variant — catch exceptions, provide fallback values."""
64
+ try:
65
+ return await call_next(message)
66
+ except Exception as exc:
67
+ return self._handle_exception(exc, message)
68
+
69
+ def _handle_exception(self, exc: Exception, message: RabbitMessage) -> Any:
70
+ """Handle an exception with registered handlers or re-raise.
71
+
72
+ Terminal exceptions (tagged with _rabbitkit_terminal=True by RetryMiddleware)
73
+ are only swallowed if swallow_permanent=True.
74
+ """
75
+ is_terminal = getattr(exc, "_rabbitkit_terminal", False)
76
+
77
+ if is_terminal and not self._swallow_permanent:
78
+ logger.error(
79
+ "Terminal exception (permanent/exhausted): %s: %s",
80
+ type(exc).__name__,
81
+ exc,
82
+ )
83
+ raise
84
+
85
+ # Try registered handlers
86
+ for exc_type, handler in self._handlers.items():
87
+ if isinstance(exc, exc_type):
88
+ logger.warning(
89
+ "Exception handled by %s handler: %s",
90
+ exc_type.__name__,
91
+ exc,
92
+ )
93
+ return handler(exc)
94
+
95
+ # No handler found
96
+ if is_terminal and self._swallow_permanent:
97
+ logger.warning(
98
+ "Swallowing terminal exception (swallow_permanent=True): %s: %s",
99
+ type(exc).__name__,
100
+ exc,
101
+ )
102
+ return None
103
+
104
+ # Re-raise unhandled non-terminal exceptions
105
+ raise
@@ -0,0 +1,440 @@
1
+ """Prometheus metrics middleware — tracks consume and publish operations.
2
+
3
+ Protocol-based approach: works with any Prometheus-compatible client
4
+ (prometheus_client, StatsD, custom implementations, etc.).
5
+
6
+ Metric names:
7
+ - rabbitkit_messages_consumed_total Counter(queue, status)
8
+ - rabbitkit_message_processing_seconds Histogram(queue)
9
+ - rabbitkit_messages_published_total Counter(exchange, status)
10
+ - rabbitkit_message_publish_seconds Histogram(exchange)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import time
17
+ from collections.abc import Awaitable, Callable
18
+ from typing import Any, Protocol, runtime_checkable
19
+
20
+ from rabbitkit.core.config import MetricsConfig
21
+ from rabbitkit.core.message import RabbitMessage
22
+ from rabbitkit.core.types import MessageEnvelope
23
+ from rabbitkit.middleware.base import BaseMiddleware
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ── Metric names (backwards-compat aliases pointing at the default config) ─
28
+
29
+ MESSAGES_CONSUMED_TOTAL = MetricsConfig().consumed_total
30
+ MESSAGE_PROCESSING_SECONDS = MetricsConfig().processing_seconds
31
+ MESSAGES_PUBLISHED_TOTAL = MetricsConfig().published_total
32
+ MESSAGE_PUBLISH_SECONDS = MetricsConfig().publish_seconds
33
+
34
+
35
+ def _queue_label(message: RabbitMessage) -> str:
36
+ """Return the ``queue`` label value for a consumed message (M3).
37
+
38
+ Prefers the BOUND queue name (``x-rabbitkit-original-queue``, set by
39
+ the broker's ``on_message`` wrapper before any middleware runs) over the
40
+ message's routing key. A topic/``Path()`` routing key that embeds an ID,
41
+ tenant, or other per-message value is unbounded — using it directly as a
42
+ Prometheus label creates one time series per distinct value ever seen
43
+ (cardinality explosion), and the label was misnamed besides (it's a
44
+ routing key, not a queue). Falls back to ``routing_key`` only when the
45
+ header is absent — e.g. a message built directly in a test rather than
46
+ delivered through a real broker/``TestBroker`` consume path.
47
+ """
48
+ original_queue = message.headers.get("x-rabbitkit-original-queue")
49
+ if original_queue:
50
+ return str(original_queue)
51
+ return message.routing_key or "unknown"
52
+
53
+
54
+ # ── Protocol ─────────────────────────────────────────────────────────────
55
+
56
+
57
+ @runtime_checkable
58
+ class MetricsCollector(Protocol):
59
+ """Protocol for metrics collection — works with Prometheus, StatsD, etc."""
60
+
61
+ def inc_counter(self, name: str, labels: dict[str, str], value: float = 1.0) -> None:
62
+ """Increment a counter metric."""
63
+ ...
64
+
65
+ def observe_histogram(self, name: str, labels: dict[str, str], value: float) -> None:
66
+ """Observe a value on a histogram metric."""
67
+ ...
68
+
69
+ def set_gauge(self, name: str, labels: dict[str, str], value: float) -> None:
70
+ """Set a gauge metric to an absolute value (e.g. queue depth)."""
71
+ ...
72
+
73
+
74
+ # ── Prometheus implementation (optional import) ──────────────────────────
75
+
76
+
77
+ class PrometheusCollector:
78
+ """Concrete MetricsCollector that wraps the ``prometheus_client`` library.
79
+
80
+ The ``prometheus_client`` import is lazy — the library is only required
81
+ when this class is instantiated, not when the module is imported.
82
+
83
+ Usage::
84
+
85
+ collector = PrometheusCollector()
86
+ middleware = MetricsMiddleware(collector)
87
+ """
88
+
89
+ def __init__(self) -> None:
90
+ try:
91
+ import prometheus_client
92
+ except ImportError as exc:
93
+ msg = (
94
+ "PrometheusCollector requires the 'prometheus_client' package. "
95
+ "Install it with: pip install prometheus-client"
96
+ )
97
+ raise ImportError(msg) from exc
98
+
99
+ self._counters: dict[str, Any] = {}
100
+ self._histograms: dict[str, Any] = {}
101
+ self._gauges: dict[str, Any] = {}
102
+ self._prometheus_client = prometheus_client
103
+
104
+ def _get_counter(self, name: str, label_names: tuple[str, ...]) -> Any:
105
+ if name not in self._counters:
106
+ self._counters[name] = self._prometheus_client.Counter(
107
+ name,
108
+ f"rabbitkit {name}",
109
+ label_names,
110
+ )
111
+ return self._counters[name]
112
+
113
+ def _get_histogram(self, name: str, label_names: tuple[str, ...]) -> Any:
114
+ if name not in self._histograms:
115
+ self._histograms[name] = self._prometheus_client.Histogram(
116
+ name,
117
+ f"rabbitkit {name}",
118
+ label_names,
119
+ )
120
+ return self._histograms[name]
121
+
122
+ def _get_gauge(self, name: str, label_names: tuple[str, ...]) -> Any:
123
+ if name not in self._gauges:
124
+ self._gauges[name] = self._prometheus_client.Gauge(
125
+ name,
126
+ f"rabbitkit {name}",
127
+ label_names,
128
+ )
129
+ return self._gauges[name]
130
+
131
+ def inc_counter(self, name: str, labels: dict[str, str], value: float = 1.0) -> None:
132
+ """Increment a Prometheus counter."""
133
+ label_names = tuple(sorted(labels.keys()))
134
+ counter = self._get_counter(name, label_names)
135
+ counter.labels(**labels).inc(value)
136
+
137
+ def observe_histogram(self, name: str, labels: dict[str, str], value: float) -> None:
138
+ """Observe a value on a Prometheus histogram."""
139
+ label_names = tuple(sorted(labels.keys()))
140
+ histogram = self._get_histogram(name, label_names)
141
+ histogram.labels(**labels).observe(value)
142
+
143
+ def set_gauge(self, name: str, labels: dict[str, str], value: float) -> None:
144
+ """Set a Prometheus gauge to an absolute value."""
145
+ label_names = tuple(sorted(labels.keys()))
146
+ gauge = self._get_gauge(name, label_names)
147
+ gauge.labels(**labels).set(value)
148
+
149
+
150
+ # ── Middleware ────────────────────────────────────────────────────────────
151
+
152
+
153
+ class MetricsMiddleware(BaseMiddleware):
154
+ """Tracks consume and publish metrics via a pluggable MetricsCollector.
155
+
156
+ If ``collector`` is None, all operations pass through without
157
+ any overhead (no-op mode).
158
+
159
+ Usage::
160
+
161
+ collector = PrometheusCollector()
162
+ middleware = MetricsMiddleware(collector)
163
+
164
+ Or with a custom collector::
165
+
166
+ class MyCollector:
167
+ def inc_counter(self, name, labels, value=1.0): ...
168
+ def observe_histogram(self, name, labels, value): ...
169
+
170
+ middleware = MetricsMiddleware(MyCollector())
171
+
172
+ Args:
173
+ collector: Any object satisfying the MetricsCollector protocol.
174
+ None for no-op (passthrough) mode.
175
+ """
176
+
177
+ def __init__(
178
+ self,
179
+ collector: MetricsCollector | None = None,
180
+ config: MetricsConfig | None = None,
181
+ ) -> None:
182
+ self._collector = collector
183
+ self._cfg = config or MetricsConfig()
184
+
185
+ @property
186
+ def collector(self) -> MetricsCollector | None:
187
+ """The configured collector (None in no-op mode). Read by
188
+ ``HandlerPipeline``/``RetryMiddleware`` to emit settlement/retry
189
+ metrics they observe but this middleware itself cannot (M2) --
190
+ settlement happens in the pipeline's own ack-orchestration code,
191
+ outside this middleware's ``consume_scope``."""
192
+ return self._collector
193
+
194
+ @property
195
+ def config(self) -> MetricsConfig:
196
+ return self._cfg
197
+
198
+ def record_settlement(self, message: RabbitMessage, disposition: str) -> None:
199
+ """Emit the ack/nack/reject counter for a settled message (M2).
200
+
201
+ ``consume_scope``/``consume_scope_async`` only wrap handler
202
+ execution -- final settlement (ack/nack/reject per AckPolicy) is
203
+ decided by the pipeline's own ack-orchestration code, which runs
204
+ AFTER this middleware's wrapped call returns. ``HandlerPipeline``
205
+ calls this directly once a route's message is settled, if a
206
+ ``MetricsMiddleware`` is present on that route.
207
+ """
208
+ if self._collector is None:
209
+ return
210
+ name = {
211
+ "acked": self._cfg.messages_acked_total,
212
+ "nacked": self._cfg.messages_nacked_total,
213
+ "rejected": self._cfg.messages_rejected_total,
214
+ }.get(disposition)
215
+ if name is None:
216
+ return # pragma: no cover - defensive, disposition is always one of the three
217
+ self._collector.inc_counter(name, {"queue": _queue_label(message)})
218
+
219
+ # ── Consume-side ──────────────────────────────────────────────────
220
+
221
+ def consume_scope(
222
+ self,
223
+ call_next: Callable[[RabbitMessage], Any],
224
+ message: RabbitMessage,
225
+ ) -> Any:
226
+ """Wrap handler execution with metrics tracking (sync)."""
227
+ if self._collector is None:
228
+ return call_next(message)
229
+
230
+ queue = _queue_label(message)
231
+ if message.redelivered:
232
+ # Broker-redelivery rate: a sustained rise means handlers are
233
+ # dying/timing out before acking, which the success/error
234
+ # consume counters alone can't distinguish from normal traffic.
235
+ self._collector.inc_counter(
236
+ self._cfg.messages_redelivered_total,
237
+ {"queue": queue},
238
+ )
239
+ start = time.monotonic()
240
+ try:
241
+ result = call_next(message)
242
+ except BaseException:
243
+ self._collector.inc_counter(
244
+ self._cfg.consumed_total,
245
+ {"queue": queue, "status": "error"},
246
+ )
247
+ self._collector.observe_histogram(
248
+ self._cfg.processing_seconds,
249
+ {"queue": queue},
250
+ time.monotonic() - start,
251
+ )
252
+ raise
253
+ else:
254
+ self._collector.inc_counter(
255
+ self._cfg.consumed_total,
256
+ {"queue": queue, "status": "success"},
257
+ )
258
+ self._collector.observe_histogram(
259
+ self._cfg.processing_seconds,
260
+ {"queue": queue},
261
+ time.monotonic() - start,
262
+ )
263
+ return result
264
+
265
+ async def consume_scope_async(
266
+ self,
267
+ call_next: Callable[[RabbitMessage], Awaitable[Any]],
268
+ message: RabbitMessage,
269
+ ) -> Any:
270
+ """Wrap handler execution with metrics tracking (async)."""
271
+ if self._collector is None:
272
+ return await call_next(message)
273
+
274
+ queue = _queue_label(message)
275
+ if message.redelivered:
276
+ # Broker-redelivery rate — see consume_scope.
277
+ self._collector.inc_counter(
278
+ self._cfg.messages_redelivered_total,
279
+ {"queue": queue},
280
+ )
281
+ start = time.monotonic()
282
+ try:
283
+ result = await call_next(message)
284
+ except BaseException:
285
+ self._collector.inc_counter(
286
+ self._cfg.consumed_total,
287
+ {"queue": queue, "status": "error"},
288
+ )
289
+ self._collector.observe_histogram(
290
+ self._cfg.processing_seconds,
291
+ {"queue": queue},
292
+ time.monotonic() - start,
293
+ )
294
+ raise
295
+ else:
296
+ self._collector.inc_counter(
297
+ self._cfg.consumed_total,
298
+ {"queue": queue, "status": "success"},
299
+ )
300
+ self._collector.observe_histogram(
301
+ self._cfg.processing_seconds,
302
+ {"queue": queue},
303
+ time.monotonic() - start,
304
+ )
305
+ return result
306
+
307
+ # ── Publish-side ──────────────────────────────────────────────────
308
+
309
+ def publish_scope(
310
+ self,
311
+ call_next: Callable[[MessageEnvelope], Any],
312
+ envelope: MessageEnvelope,
313
+ ) -> Any:
314
+ """Wrap outgoing publish with metrics tracking (sync)."""
315
+ if self._collector is None:
316
+ return call_next(envelope)
317
+
318
+ exchange = envelope.exchange or "default"
319
+ start = time.monotonic()
320
+ try:
321
+ result = call_next(envelope)
322
+ except BaseException:
323
+ self._collector.inc_counter(
324
+ self._cfg.published_total,
325
+ {"exchange": exchange, "status": "error"},
326
+ )
327
+ self._collector.observe_histogram(
328
+ self._cfg.publish_seconds,
329
+ {"exchange": exchange},
330
+ time.monotonic() - start,
331
+ )
332
+ raise
333
+ else:
334
+ self._collector.inc_counter(
335
+ self._cfg.published_total,
336
+ {"exchange": exchange, "status": "success"},
337
+ )
338
+ self._collector.observe_histogram(
339
+ self._cfg.publish_seconds,
340
+ {"exchange": exchange},
341
+ time.monotonic() - start,
342
+ )
343
+ return result
344
+
345
+ async def publish_scope_async(
346
+ self,
347
+ call_next: Callable[[MessageEnvelope], Awaitable[Any]],
348
+ envelope: MessageEnvelope,
349
+ ) -> Any:
350
+ """Wrap outgoing publish with metrics tracking (async)."""
351
+ if self._collector is None:
352
+ return await call_next(envelope)
353
+
354
+ exchange = envelope.exchange or "default"
355
+ start = time.monotonic()
356
+ try:
357
+ result = await call_next(envelope)
358
+ except BaseException:
359
+ self._collector.inc_counter(
360
+ self._cfg.published_total,
361
+ {"exchange": exchange, "status": "error"},
362
+ )
363
+ self._collector.observe_histogram(
364
+ self._cfg.publish_seconds,
365
+ {"exchange": exchange},
366
+ time.monotonic() - start,
367
+ )
368
+ raise
369
+ else:
370
+ self._collector.inc_counter(
371
+ self._cfg.published_total,
372
+ {"exchange": exchange, "status": "success"},
373
+ )
374
+ self._collector.observe_histogram(
375
+ self._cfg.publish_seconds,
376
+ {"exchange": exchange},
377
+ time.monotonic() - start,
378
+ )
379
+ return result
380
+
381
+
382
+ # ── Prometheus exposition (M-SRE1) ────────────────────────────────────────
383
+
384
+
385
+ def metrics_app() -> Callable[[Any, Any, Any], Awaitable[None]]:
386
+ """Return a minimal ASGI app that exposes ``/metrics`` in Prometheus text format.
387
+
388
+ Requires ``prometheus_client`` (lazy-imported on first request). Mount it
389
+ behind your existing ASGI server (uvicorn, hypercorn) or the dashboard::
390
+
391
+ from rabbitkit.middleware.metrics import metrics_app
392
+ app = metrics_app()
393
+ # uvicorn rabbitkit.middleware.metrics:metrics_app (after binding the factory)
394
+
395
+ For a stdlib one-liner without an ASGI server, see :func:`start_metrics_server`.
396
+ """
397
+ try:
398
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
399
+ except ImportError as exc: # pragma: no cover
400
+ raise ImportError(
401
+ "metrics_app() requires the 'prometheus_client' package. Install it with: pip install prometheus-client"
402
+ ) from exc
403
+
404
+ async def app(scope: Any, receive: Any, send: Any) -> None:
405
+ if scope["type"] != "http" or scope.get("path") != "/metrics":
406
+ await send({"type": "http.response.start", "status": 404, "headers": []})
407
+ await send({"type": "http.response.body", "body": b"not found", "more_body": False})
408
+ return
409
+ body = generate_latest()
410
+ headers = [[b"content-type", CONTENT_TYPE_LATEST.encode()]]
411
+ await send({"type": "http.response.start", "status": 200, "headers": headers})
412
+ await send({"type": "http.response.body", "body": body, "more_body": False})
413
+
414
+ return app
415
+
416
+
417
+ def start_metrics_server(port: int = 8000, host: str = "127.0.0.1") -> None:
418
+ """Start a background HTTP server exposing ``/metrics`` on ``port``.
419
+
420
+ Thin wrapper around ``prometheus_client.start_http_server``. Call once at
421
+ process startup (e.g. in a ``RabbitApp.on_startup`` hook). For k8s, scrape
422
+ this port with a ``ServiceMonitor`` / ``PodMonitor``.
423
+
424
+ The default ``host`` is ``127.0.0.1`` (loopback only) so the metrics
425
+ endpoint is not exposed to the network by default. For k8s / multi-host
426
+ scrapers pass ``host="0.0.0.0"`` explicitly and restrict access with a
427
+ NetworkPolicy (the metrics endpoint is unauthenticated and exposes broker
428
+ topology/throughput — never expose it publicly without authn in front).
429
+
430
+ Requires ``prometheus_client``.
431
+ """
432
+ try:
433
+ from prometheus_client import start_http_server
434
+ except ImportError as exc: # pragma: no cover
435
+ raise ImportError(
436
+ "start_metrics_server() requires the 'prometheus_client' package. "
437
+ "Install it with: pip install prometheus-client"
438
+ ) from exc
439
+ start_http_server(port, host)
440
+ logger.info("Prometheus metrics server on http://%s:%d/metrics", host, port)