Skip to content

Commit 9968d81

Browse files
OriNachumclaude
andcommitted
fix: harden live-overview probes (Qodo review of #52 + colleague)
- _metrics.parse_metrics: skip non-finite (NaN/inf) values — int() would raise and break the best-effort contract (Qodo #1). - _metrics.http_get_text: cap the body at 5 MiB (read max_bytes+1, treat overflow as unavailable) so a misbehaving backend can't stress memory (Qodo #2). - gateway /status: drop base_url from the payload — it's internal-only routing detail and /status may be reached over a public tunnel; matches the documented schema (Qodo #3). - gateway fleet_status_payload: probe backends in parallel (ThreadPoolExecutor) with a bounded 3s timeout, so /status can't hang for timeout × N on a slow backend (Qodo #4); and probe_backend short-circuits /metrics when /health fails (colleague review). Order preserved. Tests for each; 345 pass; black/isort/flake8/bandit clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnDfFkZkXz8C68hr3AA9Qa
1 parent 116f9bb commit 9968d81

4 files changed

Lines changed: 105 additions & 14 deletions

File tree

model_gear/_metrics.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@
1010
from __future__ import annotations
1111

1212
import json
13+
import math
1314
import urllib.error
1415
import urllib.request
1516

17+
# Cap a single GET body so a misbehaving backend can't stress memory/latency. A
18+
# vLLM /metrics scrape is well under this; /health is tiny.
19+
_MAX_BODY_BYTES = 5 * 1024 * 1024
20+
1621
# The handful of vLLM series the live view reports. "busy" = running/waiting now;
1722
# "usage" = cumulative tokens + finished requests by reason. Summed across the
1823
# engine/model labels vLLM attaches (a single backend may expose >1 engine).
@@ -54,6 +59,8 @@ def parse_metrics(text: str) -> dict:
5459
val = float(value)
5560
except ValueError:
5661
continue
62+
if not math.isfinite(val):
63+
continue # NaN/inf would later make int() raise — skip (best-effort contract)
5764
brace = left.find("{")
5865
name = left[:brace] if brace >= 0 else left
5966
labels = left[brace:] if brace >= 0 else ""
@@ -83,17 +90,27 @@ def parse_metrics(text: str) -> dict:
8390
return out
8491

8592

86-
def http_get_text(url: str, *, timeout: float = 3.0) -> str | None:
87-
"""Best-effort GET → body text, or ``None`` if unreachable / non-2xx. Never raises."""
93+
def http_get_text(
94+
url: str, *, timeout: float = 3.0, max_bytes: int = _MAX_BODY_BYTES
95+
) -> str | None:
96+
"""Best-effort GET → body text, or ``None`` if unreachable / non-2xx / oversized.
97+
98+
Reads at most ``max_bytes`` (+1 to detect overflow): an over-cap body is treated
99+
as unavailable rather than buffered whole, so a misbehaving backend can't stress
100+
memory. Never raises.
101+
"""
88102
try:
89103
with urllib.request.urlopen(
90104
url, timeout=timeout
91105
) as r: # nosec B310 - http(s) only, fixed scheme
92-
if 200 <= r.status < 300:
93-
return r.read().decode("utf-8", errors="replace")
106+
if not (200 <= r.status < 300):
107+
return None
108+
data = r.read(max_bytes + 1)
109+
if len(data) > max_bytes:
110+
return None # oversized → best-effort fail rather than buffer it whole
111+
return data.decode("utf-8", errors="replace")
94112
except (urllib.error.URLError, OSError, ValueError):
95113
return None
96-
return None
97114

98115

99116
def http_get_json(url: str, *, timeout: float = 3.0) -> dict | None:
@@ -120,9 +137,9 @@ def probe_backend(base_url: str, *, timeout: float = 3.0) -> dict:
120137
``None`` when ``/metrics`` is unreachable (an engine can be loading or down).
121138
"""
122139
base = base_url.rstrip("/")
123-
healthy = health_ok(base, timeout=timeout)
140+
if not health_ok(base, timeout=timeout):
141+
# Short-circuit: a down backend has no useful /metrics, so skip the second
142+
# request (halves the timeout cost for a dead backend).
143+
return {"health": "unreachable", "metrics": None}
124144
raw = http_get_text(base + "/metrics", timeout=timeout)
125-
return {
126-
"health": "ok" if healthy else "unreachable",
127-
"metrics": parse_metrics(raw) if raw is not None else None,
128-
}
145+
return {"health": "ok", "metrics": parse_metrics(raw) if raw is not None else None}

model_gear/gateway/server.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import http.client
1919
import json
2020
import sys
21+
from concurrent.futures import ThreadPoolExecutor
2122
from dataclasses import dataclass, field
2223
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
2324
from typing import Callable, Iterable
@@ -341,6 +342,10 @@ def handle_audio_post(
341342
# one JSON the host-side `model overview --live` renders. The prober is injected so
342343
# this is unit-testable without sockets.
343344

345+
# Per-backend probe timeout for /status: bounded + probed in parallel (below) so a
346+
# slow/down backend can't make the whole /status call hang for connect_timeout × N.
347+
_STATUS_PROBE_TIMEOUT = 3.0
348+
344349

345350
def _endpoints_for(table: RoutingTable, audio: bool) -> list[str]:
346351
"""OpenAI endpoints this gateway actually serves, by the task families present."""
@@ -365,11 +370,24 @@ def _endpoints_for(table: RoutingTable, audio: bool) -> list[str]:
365370
def fleet_status_payload(
366371
table: RoutingTable, cfg: ServerConfig, probe=_metrics.probe_backend
367372
) -> dict:
368-
"""Live status for every backend + an aggregate busy count + the endpoint list."""
373+
"""Live status for every backend + an aggregate busy count + the endpoint list.
374+
375+
Backends are probed **in parallel** with a bounded timeout, so a slow/down
376+
backend can't make ``/status`` hang for ``timeout × N``. ``base_url`` is
377+
intentionally **not** in the payload — those are internal-only routing details
378+
and ``/status`` may be reached over a public tunnel.
379+
"""
380+
members = list(table.backends)
381+
if members:
382+
with ThreadPoolExecutor(max_workers=len(members)) as pool:
383+
results = list(
384+
pool.map(lambda b: probe(b.base_url, timeout=_STATUS_PROBE_TIMEOUT), members)
385+
)
386+
else:
387+
results = []
369388
backends: list[dict] = []
370389
running = waiting = 0
371-
for b in table.backends:
372-
st = probe(b.base_url, timeout=cfg.connect_timeout)
390+
for b, st in zip(members, results):
373391
metrics = st.get("metrics") or {}
374392
running += int(metrics.get("running", 0) or 0)
375393
waiting += int(metrics.get("waiting", 0) or 0)
@@ -378,7 +396,6 @@ def fleet_status_payload(
378396
"name": b.name,
379397
"task": b.task,
380398
"served_name": b.served_name,
381-
"base_url": b.base_url,
382399
"health": st.get("health", "unreachable"),
383400
"metrics": st.get("metrics"),
384401
}

tests/test_gateway_status.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ def fake_probe(base_url, *, timeout):
7878
assert embed["health"] == "unreachable" and embed["metrics"] is None
7979
primary = next(b for b in payload["backends"] if b["name"] == "primary")
8080
assert primary["task"] == "generate" and primary["served_name"] == "P"
81+
# base_url is internal-only routing detail — must NOT leak in /status (Qodo).
82+
assert all("base_url" not in b for b in payload["backends"])
83+
84+
85+
def test_fleet_status_probes_in_parallel_preserve_order() -> None:
86+
# Parallel fan-out must still return backends in table order.
87+
seen = []
88+
89+
def probe(base_url, *, timeout):
90+
seen.append(base_url)
91+
return {"health": "ok", "metrics": {"running": 1, "waiting": 0}}
92+
93+
payload = S.fleet_status_payload(_table(), _cfg(), probe=probe)
94+
assert [b["name"] for b in payload["backends"]] == ["primary", "embed", "rerank"]
95+
assert payload["busy"] == {"running": 3, "waiting": 0} # 3 backends × running 1
8196

8297

8398
def test_fleet_status_payload_all_unreachable() -> None:

tests/test_overview_live.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,48 @@ def test_parse_metrics_skips_malformed_lines() -> None:
5252
assert m["running"] == 4
5353

5454

55+
def test_parse_metrics_skips_non_finite() -> None:
56+
# NaN/inf must be dropped, not crash the later int() (Qodo: best-effort contract).
57+
text = (
58+
'vllm:num_requests_running{e="0"} nan\n'
59+
'vllm:num_requests_running{e="1"} inf\n'
60+
'vllm:num_requests_running{e="2"} 2.0\n'
61+
)
62+
assert _metrics.parse_metrics(text)["running"] == 2
63+
64+
65+
# --- http_get_text body cap + probe short-circuit -------------------------
66+
67+
68+
class _FakeResp:
69+
def __init__(self, data: bytes, status: int = 200) -> None:
70+
self._data, self.status = data, status
71+
72+
def read(self, n: int = -1) -> bytes:
73+
return self._data[:n] if n and n > 0 else self._data
74+
75+
def __enter__(self):
76+
return self
77+
78+
def __exit__(self, *a):
79+
return False
80+
81+
82+
def test_http_get_text_caps_oversized_body(monkeypatch) -> None:
83+
monkeypatch.setattr("urllib.request.urlopen", lambda url, timeout=0: _FakeResp(b"x" * 1000))
84+
assert _metrics.http_get_text("http://x/m", max_bytes=100) is None # over cap → unavailable
85+
assert _metrics.http_get_text("http://x/m", max_bytes=5000) == "x" * 1000
86+
87+
88+
def test_probe_backend_short_circuits_when_unhealthy(monkeypatch) -> None:
89+
calls: list[str] = []
90+
monkeypatch.setattr(_metrics, "health_ok", lambda base, **k: False)
91+
monkeypatch.setattr(_metrics, "http_get_text", lambda url, **k: calls.append(url))
92+
st = _metrics.probe_backend("http://dead:8000")
93+
assert st == {"health": "unreachable", "metrics": None}
94+
assert calls == [] # /metrics never fetched for a down backend
95+
96+
5597
# --- section builders (pure) ----------------------------------------------
5698

5799

0 commit comments

Comments
 (0)