From c0c297b258a2ab9c9ebac8b8a9f87fe7ebf10ffc Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 3 Nov 2026 11:26:59 +0300 Subject: [PATCH] Serve both FastStream probe path spellings Closes #397 --- docs/integrations/faststream.md | 7 +++++ .../bootstrappers/faststream_bootstrapper.py | 20 +++++++++--- tests/test_faststream_bootstrap.py | 31 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/docs/integrations/faststream.md b/docs/integrations/faststream.md index e6ef526..bdb35bc 100644 --- a/docs/integrations/faststream.md +++ b/docs/integrations/faststream.md @@ -51,6 +51,13 @@ application = bootstrapper.bootstrap() Read more about available configuration options [here](../introduction/configuration.md). +## Health and metrics routes + +When configured, FastStream health and Prometheus metrics routes accept requests with or without +the trailing slash. For example, both `/health` and `/health/` reach the same health check, and a +metrics path configured as `/metrics` also serves `/metrics/`. If the two configured paths differ only by a trailing slash, each +route keeps its explicitly configured path. + ## Multiple brokers Tracing, metrics and logging cover every broker the application has when `bootstrap()` runs. Add diff --git a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py index 18323e2..cb8dc40 100644 --- a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py @@ -114,6 +114,11 @@ def _middleware_targets( return targets +def _route_path_variants(path: str) -> tuple[str, ...]: + alternate_path = path.removesuffix("/") if path.endswith("/") else f"{path}/" + return (path, alternate_path) if alternate_path else (path,) + + def _make_asgi_faststream() -> "AsgiFastStream": return AsgiFastStream() @@ -154,7 +159,14 @@ async def check_health(_: object) -> "AsgiResponse": if config.opentelemetry_generate_health_check_spans and import_checker.is_opentelemetry_installed: check_health = tracer.start_as_current_span(f"GET {config.health_checks_path}")(check_health) - config.application.mount(config.health_checks_path, check_health) + for path in _route_path_variants(config.health_checks_path): + if ( + path != config.health_checks_path + and path == config.prometheus_metrics_path + and FastStreamPrometheusInstrument.dependencies_installed() + ): + continue + config.application.mount(path, check_health) async def _define_framework_health_status(self) -> bool: config = self.bootstrap_config @@ -236,9 +248,9 @@ def dependencies_installed() -> bool: def bootstrap(self) -> None: config = self.bootstrap_config - config.application.mount( - config.prometheus_metrics_path, prometheus_client.make_asgi_app(self.collector_registry) - ) + metrics_app = prometheus_client.make_asgi_app(self.collector_registry) + for path in _route_path_variants(config.prometheus_metrics_path): + config.application.mount(path, metrics_app) if config.prometheus_middleware_cls and config.application.brokers: for broker, middleware_cls in _middleware_targets( config.application.brokers, config.prometheus_middleware_cls, "prometheus_middleware_cls" diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index 92b52de..e5b89cd 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -127,6 +127,37 @@ async def test_faststream_bootstrap(broker: RedisBroker) -> None: assert not bootstrapper.is_bootstrapped +@pytest.mark.parametrize("path", ["/health", "/health/", "/metrics", "/metrics/"]) +async def test_faststream_route_paths_accept_both_slash_forms(broker: RedisBroker, path: str) -> None: + config = dataclasses.replace( + build_faststream_config(broker=broker), health_checks_path="/health/", prometheus_metrics_path="/metrics" + ) + application = FastStreamBootstrapper(bootstrap_config=config).bootstrap() + + with TestClient(app=application) as test_client: + async with TestRedisBroker(broker): + response = test_client.get(path, follow_redirects=False) + + assert response.status_code == status.HTTP_200_OK + + +async def test_faststream_configured_metrics_path_takes_precedence_over_health_alias(broker: RedisBroker) -> None: + config = dataclasses.replace( + build_faststream_config(broker=broker), health_checks_path="/health/", prometheus_metrics_path="/health" + ) + application = FastStreamBootstrapper(bootstrap_config=config).bootstrap() + + with TestClient(app=application) as test_client: + async with TestRedisBroker(broker): + response = test_client.get("/health") + health_response = test_client.get("/health/") + + assert health_response.status_code == status.HTTP_200_OK + assert health_response.json()["health_status"] is True + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-type"].startswith("text/plain") + + async def test_faststream_sentry_context_is_isolated_per_message(broker: RedisBroker) -> None: transport = SentryTestTransport() config = FastStreamConfig(