fix(gateway): keep the Prometheus /metrics Mount in the gateway route trim (#32317)

This commit is contained in:
Yassin Kortam 2026-07-07 18:36:38 +03:00 committed by GitHub
parent a78dc69a09
commit 4a769c954e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 110 additions and 9 deletions

View file

@ -25,17 +25,25 @@ 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:
"""Keep the route on the gateway if its path is in the LLM data-plane surface."""
"""Keep the route on the gateway if its path is in the LLM data-plane surface.
Prometheus registers /metrics as a Mount (``app.mount("/metrics", make_asgi_app())``),
so Mounts are matched against GATEWAY_MOUNT_PATHS instead of being dropped with
the UI static mounts.
"""
path = getattr(route, "path", None)
if path is None:
return False
if isinstance(route, Mount):
# Gateway never serves the static UI or its asset bundles.
return False
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

@ -106,7 +106,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
# Health & ops
"/health",
"/metrics",
"/watsonx"
"/watsonx",
)
GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
@ -120,3 +120,9 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
"/test",
}
)
GATEWAY_MOUNT_PATHS: frozenset[str] = frozenset(
{
"/metrics",
}
)

View file

@ -11,10 +11,16 @@ clients hitting that path on the corresponding pod get a 404. This test
guarantees that the union of the two trimmed route sets equals the full set
of routes on the proxy app i.e. no endpoint is dropped on the floor.
The test reproduces the same predicate that ``gateway/main.py`` and
``backend/main.py`` use, without importing them. The component modules wrap
The union-coverage test reproduces the same predicate that ``gateway/main.py``
and ``backend/main.py`` use, without importing them. The component modules wrap
the shared ``app.router.lifespan_context``; importing them in the test process
would chain wrappers and corrupt the snapshot.
would chain wrappers and corrupt the snapshot. The gateway Mount tests below
import the real ``gateway.main._is_gateway_route`` instead, undoing both of the
module's import-time side effects: the lifespan wrapper is restored right after
the import, and the DATABASE_* env vars are popped for its duration because
``gateway.main`` runs ``DatabaseURLSettings.from_env().apply_to_env()`` at
import (which raises on a non-postgres ``DATABASE_URL`` scheme and can mint an
RDS IAM token when ``IAM_TOKEN_DB_AUTH`` is set).
"""
import os
@ -36,6 +42,7 @@ for _key, _value in _THROWAWAY_ENV.items():
os.environ.setdefault(_key, _value)
from fastapi.routing import Mount
from prometheus_client import make_asgi_app
# gateway/ and backend/ live at the repo root, not inside litellm/.
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
@ -47,7 +54,11 @@ 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
for _key, _previous in _PRE_EXISTING_ENV.items():
@ -56,6 +67,24 @@ for _key, _previous in _PRE_EXISTING_ENV.items():
else:
os.environ[_key] = _previous
_DB_ENV_KEYS = (
"DATABASE_URL",
"DIRECT_URL",
"DATABASE_URL_READ_REPLICA",
"DATABASE_HOST",
"DATABASE_HOST_READ_REPLICA",
"DATABASE_PASSWORD",
"IAM_TOKEN_DB_AUTH",
)
_PRE_DB_ENV = {_key: os.environ.pop(_key, None) for _key in _DB_ENV_KEYS}
_PRE_COMPONENT_LIFESPAN = app.router.lifespan_context
from gateway.main import _is_gateway_route
app.router.lifespan_context = _PRE_COMPONENT_LIFESPAN
for _key, _previous in _PRE_DB_ENV.items():
if _previous is not None:
os.environ[_key] = _previous
def _component_paths(routes, exact_paths, path_prefixes) -> set[str]:
"""Reproduce ``gateway.main._is_gateway_route`` / ``backend.main._is_backend_route``."""
@ -133,3 +162,61 @@ 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 expose /metrics."""
assert isinstance(GATEWAY_MOUNT_PATHS, frozenset), \
f"GATEWAY_MOUNT_PATHS must be a frozenset, got {type(GATEWAY_MOUNT_PATHS)}"
assert "/metrics" in GATEWAY_MOUNT_PATHS, \
"/metrics Mount path must be in GATEWAY_MOUNT_PATHS"
def test_gateway_trim_keeps_metrics_mount():
"""The Prometheus /metrics Mount must survive the gateway route trim.
Regression test for https://github.com/BerriAI/litellm/issues/30291:
``_is_gateway_route`` used to reject every Mount before the allowlist
check, so the /metrics Mount registered by
``PrometheusLogger._mount_metrics_endpoint()`` was dropped at startup and
the gateway returned 404 on /metrics.
"""
metrics_mount = Mount("/metrics", app=make_asgi_app())
routes = [*app.router.routes, metrics_mount]
trimmed = [r for r in routes if _is_gateway_route(r)]
assert metrics_mount in trimmed, \
"/metrics Mount must survive the gateway route trim"
def test_gateway_drops_ui_and_swagger_mounts():
"""UI static and swagger Mounts must still be trimmed from the gateway."""
for path in ("/ui", "/_next", "/litellm-asset-prefix/_next", "/swagger"):
assert not _is_gateway_route(Mount(path, app=make_asgi_app())), \
f"Mount {path} must not be served by the gateway"
def test_every_app_mount_is_assigned_to_a_component():
"""Every Mount on the proxy app must be consciously assigned to a component.
A Mount must be kept by the gateway (GATEWAY_MOUNT_PATHS), kept by the
backend (BACKEND_MOUNT_PATHS), or be a static mount served by the
dedicated UI container. A Mount matching none of these is unreachable in
a componentized deployment, which is exactly how the /metrics Mount was
silently dropped.
"""
ui_served_prefixes = ("/ui", "/_next", "/litellm-asset-prefix")
mounts = [*app.router.routes, Mount("/metrics", app=make_asgi_app())]
unassigned = {
path
for r in mounts
if isinstance(r, Mount)
and (path := getattr(r, "path", None)) is not None
and path not in GATEWAY_MOUNT_PATHS
and path not in BACKEND_MOUNT_PATHS
and not path.startswith(ui_served_prefixes)
}
assert not unassigned, (
f"{len(unassigned)} Mount(s) are not exposed on any component. "
f"Add them to GATEWAY_MOUNT_PATHS, BACKEND_MOUNT_PATHS, or serve them "
f"from the UI container:\n " + "\n ".join(sorted(unassigned))
)