---
ogImage: "/og/when-a-proxy-timeout-returns-html-instead-of-json.jpg"
banner: "/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg"
title: "When a Proxy Timeout Returns HTML Instead of JSON: A 499 Post-Mortem"
description: "Why a self-hosted admin card failed with 'Unexpected token <': an O(n) library audit running inside a GET request, three reverse-proxy hops, and the 499 status no monitor watches — measured from 13.6 s on an idle box to 2,507 s on a busy one."
pubDate: 2026-10-28
category: engineering
blueprint: self-hosted-infrastructure
blueprintLayer: L2-edge-proxy-runtime
tags: ["nginx", "fastapi", "reverse-proxy", "timeouts", "caching", "observability", "stale-while-revalidate"]
draft: false
summary:
audience: "Full-Stack Engineers, SREs & Platform Architects Who Own Their Own Edge"
painPoint: "An admin dashboard card died with a JavaScript syntax error, which pointed every investigation at the frontend — while the real failure was an O(n) audit burning one pinned CPU core behind three proxy hops that silently abort at their own timeouts."
impact:
- metric: "Endpoint Latency −98%"
detail: "Moved a 289-to-341-note library audit off the request thread into a single-flight background refresher; the cached read path now answers in 0.24–0.36 s over five consecutive calls instead of 13.6 s idle and 2,507 s under load."
- metric: "Failure Now Names Its Layer"
detail: "Replaced a blind res.json() with a text-first reader that preserves the HTTP status and the first 80 bytes of an HTML error page, so a proxy abort reports 'HTTP 503 ·
🔍 View the stale-while-revalidate implementation — cache, single-flight refresh thread, startup warm (52 lines)
```python
_AUTOMATION_TTL_SEC = float(os.environ.get("AUTOMATION_COUNTS_TTL_SEC", "300") or 300)
_automation_cache: Dict[str, Any] = {"data": None, "at": 0.0}
_automation_refreshing_flag = [False]
_automation_lock = threading.RLock()
@app.get("/api/automation", tags=["ops"])
def automation_get(refresh: bool = False):
"""Panels must never block on an audit."""
flags = autoflow.settings(db=db)
now = time.time()
cached = dict(_automation_cache)
fresh = cached["data"] is not None and (now - cached["at"]) < _AUTOMATION_TTL_SEC
if refresh or not fresh:
_start_automation_refresh()
return {
"flags": flags,
"counts": (cached["data"] or {}).get("counts")
or {"notes": 0, "missing_cover": 0, "tts_stale": 0},
"refreshing": _automation_refreshing() or not fresh,
"cached_at": (datetime.fromtimestamp(cached["at"]).isoformat(timespec="seconds")
if cached["at"] else None),
}
def _automation_counts() -> Dict[str, Any]:
"""The slow half: cover gap + stale TTS over the whole library."""
notes = [p for p in sorted(CONTENT_DIR.glob("*.md")) if not p.stem.endswith(".en")]
missing_cover = [...]
stale = tts_mod.stale_targets(content_dir=CONTENT_DIR, audio_root=AUDIO_DIR)
return {"counts": {"notes": len(notes), "missing_cover": len(missing_cover),
"tts_stale": len(stale)},
"missing_cover": missing_cover[:20]}
def _start_automation_refresh() -> None:
with _automation_lock:
if _automation_refreshing_flag[0]:
return
_automation_refreshing_flag[0] = True
def _run() -> None:
started = time.time()
try:
data = _automation_counts()
_automation_cache.update({"data": data, "at": time.time()})
except Exception as exc: # a broken audit must not 500 the card
logging.warning("automation counts failed: %s", exc)
finally:
_automation_refreshing_flag[0] = False
logging.info("automation counts refreshed in %.1fs", time.time() - started)
threading.Thread(target=_run, name="automation-counts", daemon=True).start()
```
**2. A stale value must announce itself.** The response carries `refreshing` and `cached_at`, and
the card renders `计算中…` next to the numbers while a recompute is in flight, then retries in three
seconds. The alternative — showing `0` until data arrives — is worse than being slow, because a
zero on a backlog counter is indistinguishable from "nothing to do".
**3. The client never parses blindly.** `res.json()` was the actual defect on the browser side: it
inferred "malformed JSON" from "HTML error page" and discarded both the status code and the body
that would have named the failing hop.
🔍 View the text-first response reader and the retry contract (22 lines)
```javascript
function autoReadJson(res) {
// A proxy timeout / 502 / 503 returns an HTML error page. Blind res.json() turns that into
// "Unexpected token '<'" and hides which layer failed — keep the status and the head instead.
return res.text().then(function (text) {
try {
return { data: JSON.parse(text) };
} catch (e) {
var head = String(text || '').trim().slice(0, 80).replace(/\s+/g, ' ');
return { error: 'HTTP ' + res.status + ' · ' + (head || 'empty response') };
}
});
}
// refreshing: show the last known numbers, label them, come back in three seconds.
document.getElementById('auto-counts').textContent =
'笔记 ' + counts.notes + ' · 缺封面 ' + counts.missing_cover
+ ' · 待朗读 ' + counts.tts_stale + (data.refreshing ? ' · 计算中…' : '');
if (data.refreshing) { autoRetryTimer = setTimeout(window.autoReload, 3000); }
```
A fourth change is smaller but load-bearing: `?refresh=1` forces a recompute. Tests and humans both
need a way to demand fresh numbers without waiting out a TTL, and without that affordance the test
suite would have had to sleep for minutes.
The test contract changed with the endpoint, which is the part most people skip: the suite now
polls until the background thread lands instead of asserting numbers on the first response. 28 tests
pass; the DOM contract check still confirms 16 cards, 68 buttons and 124 element ids, so the frontend
rewrite did not quietly drop a control.
---
## Reflection: five rules for panel endpoints
1. **A dashboard endpoint is not a query interface.** It is the most frequently called and least
cached surface in an admin UI. Anything O(n) over user data belongs in a background job with a
publish step, not in the GET that a browser polls on focus.
2. **Your timeout budget is the smallest hop, not the sum.** Four proxies each "allowing" 120 s do
not add up; the request dies at whichever clock expires first, and the application cannot see any
of them. The application-side budget has to be an order of magnitude under the smallest one.
3. **`499` deserves an alert.** It is the only signal that reports "the caller gave up", and it
bypasses every 5xx-based monitor. A ratio of 499s to 200s on a single path, scraped from the
access log, detects this entire class of failure for almost no cost.
4. **Never trust `res.json()` on a proxied route.** Read text, keep the status, keep the first line.
The error message is the only artefact the next engineer will have.
5. **Stale-while-revalidate beats a bigger number.** Raising a timeout moves the wall; decoupling
compute from read removes it — and it removes it for the worst case you have not measured yet
(2,507 s here), not just the one you did (13.6 s).
---
## The result
- **Cached read path: 200 in 0.24–0.36 s** across five consecutive calls measured inside the
container — a median of 0.26 s against the 13.6 s audit it replaced, **52× faster at the median
and 38–57× across the spread**.
- **Worst observed case eliminated as a user-facing risk.** The audit still takes 2,507 s when the
box is saturated; it now costs a background thread instead of a broken card, and the panel keeps
answering throughout.
- **Correctness preserved, not masked.** Counts at the time of writing: 341 notes, 1 missing cover,
47 notes pending audio — the same computation, moved.
- **The failure can no longer masquerade.** If any hop aborts again, the card prints
`HTTP 503 · …` and the investigation starts at the right layer.
The single most useful line in this whole exercise was the one that lied: `Unexpected token '<'`.
An HTML error page handed to a JSON caller is not a syntax error — it is a proxy telling you, in the
only language it has, that it stopped waiting before your code ever woke up.