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
This commit is contained in:
Manan Bansal 2026-06-13 12:00:29 +05:30
parent c90eb7e96f
commit ae10154f78
3 changed files with 61 additions and 4 deletions

View file

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

View file

@ -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
}
)

View file

@ -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