From ae10154f782f1999d08f3530c32b2450350a7868 Mon Sep 17 00:00:00 2001 From: Manan Bansal Date: Sat, 13 Jun 2026 12:00:29 +0530 Subject: [PATCH] fix(gateway): keep prometheus /metrics mount when trimming gateway routes The componentized gateway trims the shared proxy route table to the data-plane allowlist, but _is_gateway_route dropped every Mount unconditionally. Prometheus registers /metrics as a Mount (make_asgi_app), not an APIRoute, so the gateway returned 404 on /metrics even though gateway/routes/allowlist.py lists it. Add a GATEWAY_MOUNT_PATHS allowlist (mirroring the BACKEND_MOUNT_PATHS pattern) holding /metrics, and keep a Mount only when its path is in it. The UI static mounts and the MCP catch-all Mount stay dropped. Fixes #30291 --- gateway/main.py | 12 +++-- gateway/routes/allowlist.py | 6 +++ .../proxy/test_component_allowlists.py | 47 ++++++++++++++++++- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/gateway/main.py b/gateway/main.py index 09d30f5da3f..78cfc3d5675 100644 --- a/gateway/main.py +++ b/gateway/main.py @@ -25,7 +25,11 @@ DatabaseURLSettings.from_env().apply_to_env() from litellm.proxy.proxy_server import app -from gateway.routes.allowlist import GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES +from gateway.routes.allowlist import ( + GATEWAY_EXACT_PATHS, + GATEWAY_MOUNT_PATHS, + GATEWAY_PATH_PREFIXES, +) def _is_gateway_route(route) -> bool: @@ -34,8 +38,10 @@ def _is_gateway_route(route) -> bool: if path is None: return False if isinstance(route, Mount): - # Gateway never serves the static UI or its asset bundles. - return False + # The static UI mounts are served by the dedicated UI container. Only + # Mounts in the gateway allowlist (e.g. the Prometheus /metrics scrape, + # registered via make_asgi_app) remain on the gateway. + return path in GATEWAY_MOUNT_PATHS if path in GATEWAY_EXACT_PATHS: return True return any(path.startswith(prefix) for prefix in GATEWAY_PATH_PREFIXES) diff --git a/gateway/routes/allowlist.py b/gateway/routes/allowlist.py index 144bb4c473f..90bcc9a3151 100644 --- a/gateway/routes/allowlist.py +++ b/gateway/routes/allowlist.py @@ -120,3 +120,9 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset( "/test", } ) + +GATEWAY_MOUNT_PATHS: frozenset[str] = frozenset( + { + "/metrics", # Prometheus scrape endpoint, registered as an ASGI Mount + } +) diff --git a/tests/test_litellm/proxy/test_component_allowlists.py b/tests/test_litellm/proxy/test_component_allowlists.py index 926ce3bee66..22f504b0127 100644 --- a/tests/test_litellm/proxy/test_component_allowlists.py +++ b/tests/test_litellm/proxy/test_component_allowlists.py @@ -47,9 +47,20 @@ from backend.routes.allowlist import ( BACKEND_MOUNT_PATHS, BACKEND_PATH_PREFIXES, ) -from gateway.routes.allowlist import GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES +from gateway.routes.allowlist import ( + GATEWAY_EXACT_PATHS, + GATEWAY_MOUNT_PATHS, + GATEWAY_PATH_PREFIXES, +) from litellm.proxy.proxy_server import app +# Importing gateway.main wraps the shared app's lifespan_context; save and +# restore it so the wrapper does not leak into the other tests in this module. +_PRE_IMPORT_LIFESPAN = app.router.lifespan_context +from gateway.main import _is_gateway_route + +app.router.lifespan_context = _PRE_IMPORT_LIFESPAN + for _key, _previous in _PRE_EXISTING_ENV.items(): if _previous is None: os.environ.pop(_key, None) @@ -133,3 +144,37 @@ def test_backend_drops_non_allowlisted_mounts(): for mount_path in non_backend_mounts: assert mount_path not in BACKEND_MOUNT_PATHS, \ f"Mount {mount_path} should not be in BACKEND_MOUNT_PATHS" + + +def test_gateway_mount_paths_defined(): + """GATEWAY_MOUNT_PATHS constant must exist and be a frozenset.""" + assert isinstance(GATEWAY_MOUNT_PATHS, frozenset), \ + f"GATEWAY_MOUNT_PATHS must be a frozenset, got {type(GATEWAY_MOUNT_PATHS)}" + assert len(GATEWAY_MOUNT_PATHS) > 0, \ + "GATEWAY_MOUNT_PATHS must contain at least one Mount path" + + +def test_metrics_mount_in_gateway_allowlist(): + """The /metrics Mount must be in GATEWAY_MOUNT_PATHS.""" + assert "/metrics" in GATEWAY_MOUNT_PATHS, \ + "/metrics Mount path must be in GATEWAY_MOUNT_PATHS" + + +async def _asgi_noop(scope, receive, send) -> None: + return None + + +def test_gateway_keeps_prometheus_metrics_mount(): + """Regression for #30291. + + Prometheus registers /metrics as a Mount (``make_asgi_app``), not an + APIRoute. The gateway trim used to drop every Mount, so /metrics returned + 404 on ``gateway.main`` even though the allowlist lists it. + """ + assert _is_gateway_route(Mount("/metrics", app=_asgi_noop)) is True + + +def test_gateway_drops_non_allowlisted_mounts(): + """Keeping /metrics must not leak the UI static or MCP catch-all Mounts.""" + for path in ("/", "/ui", "/_next", "/swagger", "/mcp", "/sse"): + assert _is_gateway_route(Mount(path, app=_asgi_noop)) is False, path