mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix(ui): revalidate dashboard HTML on every load and answer /health/readiness
The UI image sent its HTML with no Cache-Control while the content-hashed _next bundles are immutable for a year, so a browser could keep stale HTML across a deploy and request chunk hashes the new image no longer ships, failing with 404 until a hard reload. The HTML locations now send Cache-Control: no-cache so the browser revalidates against ETag on each load. nginx also answers 200 on /health/readiness, the path the gateway exposes, so an ingress-wide ALB health check on that path no longer marks the UI target unhealthy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
b877a38e5f
commit
b99d8ef755
2 changed files with 61 additions and 5 deletions
|
|
@ -18,6 +18,7 @@ Requires a working docker CLI.
|
|||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
|
@ -96,6 +97,17 @@ def _probe(network: str, container: str, path: str) -> "subprocess.CompletedProc
|
|||
)
|
||||
|
||||
|
||||
def _response_headers(network: str, container: str, path: str) -> dict[str, str]:
|
||||
head = _docker(
|
||||
"run", "--rm", "--network", network, CURL_IMAGE,
|
||||
"--silent", "--show-error", "--max-time", "10", "--head",
|
||||
f"http://{container}:{UI_PORT}{path}",
|
||||
check=False,
|
||||
)
|
||||
header_lines = (line for line in head.stdout.splitlines() if ":" in line)
|
||||
return {name.strip().lower(): value.strip() for name, value in (line.split(":", 1) for line in header_lines)}
|
||||
|
||||
|
||||
def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) -> None:
|
||||
"""nginx boots and serves as an arbitrary uid with a read-only root fs.
|
||||
|
||||
|
|
@ -130,3 +142,41 @@ def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) ->
|
|||
f"GET {path} returned {page.stdout.strip()!r} as uid {ARBITRARY_UID}.\n"
|
||||
f"{_container_logs(container)}"
|
||||
)
|
||||
|
||||
|
||||
def test_readiness_path_answers_200_like_the_gateway(ui_container: tuple[str, str]) -> None:
|
||||
network, container = ui_container
|
||||
|
||||
readiness = _probe(network, container, "/health/readiness")
|
||||
assert readiness.stdout.strip() == "200", (
|
||||
f"GET /health/readiness returned {readiness.stdout.strip()!r}; an ingress-wide ALB health check on "
|
||||
f"that path marks the UI target unhealthy.\n{_container_logs(container)}"
|
||||
)
|
||||
|
||||
|
||||
def test_html_revalidates_while_hashed_bundles_stay_immutable(ui_container: tuple[str, str]) -> None:
|
||||
network, container = ui_container
|
||||
|
||||
for path in ("/ui", "/ui/", "/ui/login", "/"):
|
||||
headers = _response_headers(network, container, path)
|
||||
assert headers.get("cache-control") == "no-cache", (
|
||||
f"GET {path} sent Cache-Control {headers.get('cache-control')!r}; a browser that keeps this HTML "
|
||||
f"requests the previous deploy's chunk hashes after a rollout.\n{_container_logs(container)}"
|
||||
)
|
||||
|
||||
html = _docker(
|
||||
"run", "--rm", "--network", network, CURL_IMAGE,
|
||||
"--silent", "--show-error", "--max-time", "10",
|
||||
f"http://{container}:{UI_PORT}/ui/",
|
||||
check=False,
|
||||
)
|
||||
chunk_paths = tuple(
|
||||
match.group(1) for match in re.finditer(r'src="(/litellm-asset-prefix/_next/static/[^"]+\.js)"', html.stdout)
|
||||
)
|
||||
assert chunk_paths, f"the UI HTML references no /litellm-asset-prefix/_next/static/*.js bundle:\n{html.stdout[:2000]}"
|
||||
|
||||
chunk_headers = _response_headers(network, container, chunk_paths[0])
|
||||
assert "immutable" in chunk_headers.get("cache-control", ""), (
|
||||
f"GET {chunk_paths[0]} sent Cache-Control {chunk_headers.get('cache-control')!r}; hashed bundles must "
|
||||
f"stay cacheable.\n{_container_logs(container)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -70,8 +70,10 @@ http {
|
|||
expires 1d;
|
||||
}
|
||||
|
||||
# Probe target — doesn't depend on disk.
|
||||
location = /healthz { default_type text/plain; return 200 "ok\n"; }
|
||||
# Probe targets — don't depend on disk. /health/readiness mirrors the
|
||||
# gateway's path so one ingress-wide ALB health check covers the UI too.
|
||||
location = /healthz { default_type text/plain; return 200 "ok\n"; }
|
||||
location = /health/readiness { default_type text/plain; return 200 "ok\n"; }
|
||||
|
||||
# Next.js App Router (output: "export") emits an RSC/flight payload
|
||||
# as <route>.txt next to <route>.html, plus __next.*.txt segment
|
||||
|
|
@ -95,14 +97,18 @@ http {
|
|||
# proxy_server: serve /ui/<page> from out/<page>.html, with App
|
||||
# Router-aware fallback (out/<page>/index.html) and a final SPA
|
||||
# fallback to out/index.html for client-side routes.
|
||||
location = /ui { try_files /index.html =404; }
|
||||
location = /ui/ { try_files /index.html =404; }
|
||||
# The HTML is the manifest for the content-hashed bundles above, so it
|
||||
# must revalidate on every load (ETag/Last-Modified) or a browser keeps
|
||||
# asking for chunks the previous deploy shipped and gets 404s.
|
||||
location = /ui { add_header Cache-Control "no-cache"; try_files /index.html =404; }
|
||||
location = /ui/ { add_header Cache-Control "no-cache"; try_files /index.html =404; }
|
||||
location ~ ^/ui/(.+)$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files /$1.html /$1/index.html /index.html =404;
|
||||
}
|
||||
|
||||
# `/` is handy for direct-debug port-forwards.
|
||||
location = / { try_files /index.html =404; }
|
||||
location = / { add_header Cache-Control "no-cache"; try_files /index.html =404; }
|
||||
|
||||
# Anything else (API calls etc.) returns 404 from the UI's
|
||||
# perspective. A reverse proxy in front of this image routes the
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue