diff --git a/docs/linkedin/batch-07-linkedin-posts.md b/docs/linkedin/batch-07-linkedin-posts.md new file mode 100644 index 0000000..57344d5 --- /dev/null +++ b/docs/linkedin/batch-07-linkedin-posts.md @@ -0,0 +1,53 @@ +# LinkedIn Technical Posts — Batch 07 + +> **Target Audience**: CTOs, Engineering Directors, and Principal/Staff Hiring Managers in the US, Europe, and Singapore. +> **Positioning**: Senior Full-Stack / DevOps / Platform Architect. +> **Tone & Persona**: Authoritative, production-tested, quantitative, first-person singular (`I`), zero-fluff, no-seller/no-homelab voice. +> **Status**: Ready for publication / scheduling. + +--- + +## Post 01: The Proxy That Answered a JSON Request With HTML + +* **Target Article**: `when-a-proxy-timeout-returns-html-instead-of-json` +* **Canonical URL**: `https://blog.hoelee.com/posts/when-a-proxy-timeout-returns-html-instead-of-json/` +* **Category**: Full-Stack / Edge Engineering +* **Character Count**: ~2,150 chars + +```text +A production admin card failed with one line: "SyntaxError: Unexpected token '<', "" and names the failing layer. + +Result: the cached read path answers 200 in 0.24–0.36 s across five consecutive calls. Median 0.26 s against the 13.6 s audit it replaced — 52x at the median, 38–57x across the spread. + +The most useful line in the whole post-mortem was the one that lied. + +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 woke up. + +Full measurement chain, both code diffs, and the five rules I now apply to every panel endpoint: https://blog.hoelee.com/posts/when-a-proxy-timeout-returns-html-instead-of-json/ + +What does your access-log 499 ratio look like on the endpoints nobody polls? +``` diff --git a/public/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg b/public/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg new file mode 100644 index 0000000..42538d6 Binary files /dev/null and b/public/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg differ diff --git a/public/banners/when-a-proxy-timeout-returns-html-instead-of-json.webp b/public/banners/when-a-proxy-timeout-returns-html-instead-of-json.webp new file mode 100644 index 0000000..8f0f4bf Binary files /dev/null and b/public/banners/when-a-proxy-timeout-returns-html-instead-of-json.webp differ diff --git a/public/og/thumb/when-a-proxy-timeout-returns-html-instead-of-json-400.webp b/public/og/thumb/when-a-proxy-timeout-returns-html-instead-of-json-400.webp new file mode 100644 index 0000000..6297b99 Binary files /dev/null and b/public/og/thumb/when-a-proxy-timeout-returns-html-instead-of-json-400.webp differ diff --git a/public/og/thumb/when-a-proxy-timeout-returns-html-instead-of-json-800.webp b/public/og/thumb/when-a-proxy-timeout-returns-html-instead-of-json-800.webp new file mode 100644 index 0000000..e1a7955 Binary files /dev/null and b/public/og/thumb/when-a-proxy-timeout-returns-html-instead-of-json-800.webp differ diff --git a/public/og/when-a-proxy-timeout-returns-html-instead-of-json.jpg b/public/og/when-a-proxy-timeout-returns-html-instead-of-json.jpg new file mode 100644 index 0000000..e6f8363 Binary files /dev/null and b/public/og/when-a-proxy-timeout-returns-html-instead-of-json.jpg differ diff --git a/public/og/zh-when-a-proxy-timeout-returns-html-instead-of-json.jpg b/public/og/zh-when-a-proxy-timeout-returns-html-instead-of-json.jpg new file mode 100644 index 0000000..6a78fb2 Binary files /dev/null and b/public/og/zh-when-a-proxy-timeout-returns-html-instead-of-json.jpg differ diff --git a/scripts/og-gen/art-meta.json b/scripts/og-gen/art-meta.json index b9d7f14..f98efa9 100644 --- a/scripts/og-gen/art-meta.json +++ b/scripts/og-gen/art-meta.json @@ -321,5 +321,15 @@ "evidence": "89 covers in 48m · 35% safe zone · Laplacian filter", "ogTitle_en": "Batch Generating 80+ Technical Covers", "ogTitle_zh": "ComfyUI 批量闭环 80+ 张技术封面" + }, + "when-a-proxy-timeout-returns-html-instead-of-json": { + "evidence": "13.6 s -> 0.26 s · 2507 s worst · 499 at the gate", + "ogTitle_en": "The Proxy That Replied in HTML", + "ogTitle_zh": "反代用 HTML 回答了 JSON", + "tags": [ + "nginx", + "timeouts", + "caching" + ] } } \ No newline at end of file diff --git a/src/content/posts/when-a-proxy-timeout-returns-html-instead-of-json.md b/src/content/posts/when-a-proxy-timeout-returns-html-instead-of-json.md new file mode 100644 index 0000000..d719435 --- /dev/null +++ b/src/content/posts/when-a-proxy-timeout-returns-html-instead-of-json.md @@ -0,0 +1,298 @@ +--- +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. diff --git a/src/content/posts/zh/when-a-proxy-timeout-returns-html-instead-of-json.md b/src/content/posts/zh/when-a-proxy-timeout-returns-html-instead-of-json.md new file mode 100644 index 0000000..1d4abdd --- /dev/null +++ b/src/content/posts/zh/when-a-proxy-timeout-returns-html-instead-of-json.md @@ -0,0 +1,233 @@ +--- +ogImage: "/og/zh-when-a-proxy-timeout-returns-html-instead-of-json.jpg" +banner: "/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg" +title: "反代超时返回 HTML,前端报 SyntaxError:一次 499 事故复盘" +description: "后台卡片报「Unexpected token '<'」,所有线索都指向前端,真正的故障却是一个跑在 GET 请求线程里的全库审计:289 到 341 篇笔记逐一切章、算哈希、stat 音频,穿过三层反代,实测从空闲时的 13.6 秒恶化到满载时的 2507 秒。" +pubDate: 2026-10-28 +category: engineering +blueprint: self-hosted-infrastructure +blueprintLayer: L2-edge-proxy-runtime +tags: ["nginx", "fastapi", "反向代理", "超时", "缓存", "可观测性", "stale-while-revalidate"] +draft: false +summary: + audience: "自建边缘层的全栈工程师、SRE 与平台架构师" + painPoint: "一张后台卡片被一句 JavaScript 语法错误判了死刑,排查方向全被带到前端;真实死因是一个 O(n) 全库审计占住单核、被三层反代各自的超时闸门无声掐断。" + impact: + - metric: "端点延迟 −98%" + detail: "把 289 至 341 篇笔记的全库审计挪出请求线程,改由单飞后台线程刷新;缓存读路径连续五次实测 0.24–0.36 秒,替代空闲时 13.6 秒、满载时 2507 秒的同步审计。" + - metric: "故障自动指明层级" + detail: "用「先读文本、保留状态码与首 80 字节」的读取器替代裸 res.json(),反代中断时面板直接打印「HTTP 503 · + +线上面板报上来只有一行字: + +``` +读取自动处理状态失败:SyntaxError: Unexpected token '<', " + +出问题的路由是发布控制台的一张状态卡片: + +``` +GET /api/automation +``` + +它返回三个开关和三个计数。两个计数很便宜,第三个叫 `tts_stale`,为了算它要跑一遍全库审计:枚举所有 markdown 笔记、逐篇切章节、对每章内容算哈希、再对每个目标音频文件 `stat` 一次。而这一切**就跑在请求线程里**,卡片每轮询一次就重算一次。 + +```python +# 出问题的写法:一个 GET,顺手把整个库量一遍 +@app.get("/api/automation") +def automation_get(): + 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) # O(n) 审计 + return {"counts": {"notes": len(notes), "tts_stale": len(stale)}, ...} +``` + +289 篇笔记时这是一次 13.6 秒的请求。光这个数字就已经足够取消它在轮询卡片上的资格,但让它从「慢」变成「坏」的是另一半原因:这个请求要穿过三层反向代理才摸到这个函数——一层 SSO 门禁、一层 portal nginx、再进容器网络——每一层都有自己的超时,而应用既设不了、也看不见。 + +portal 的 `/api/` 位置在现行配置里是 `proxy_read_timeout 120s`,而它前面的 SSO 门禁放弃得更早。任何一层的计时器一到,它就按 nginx 的本分处理:停止等待、断开上游连接、把**自己的 HTML 错误页**交给浏览器。前端接着对这份文档执行 `await res.json()`,于是就有了那行 `SyntaxError`。 + +整条链上没有任何一环返回 500。应用从头到尾都不知道这个请求存在过。 + +--- + +## 三条证据钉死「其实什么都没坏」 + +三个产物合起来就能定位故障,不需要猜。单独看每一条都是歧义的,其中一条还会主动误导。 + +| 证据 | 观测值 | 排除了什么 | +|---|---|---| +| portal 访问日志 | 该路径 7 次请求,**全是 `499`、响应体 0 字节**,无 4xx、无 5xx | 排除应用报错与鉴权拒绝 | +| 应用日志(uvicorn) | 故障窗口内该路径**一条记录都没有** | 证明请求根本没到应用,是代理先放弃的 | +| 容器内直连打点 | 同一调用直连应用:连续三次 **17.7 秒 / 98.5 秒 / 35.8 秒** | 确认慢是真的、且慢在审计内部,不是网络抖动 | + +`499` 是 nginx 在**客户端**先断开连接时写下的状态码。它不是服务端承认的错误,不是 5xx,FastAPI 的访问日志也永远看不到它——所以这张卡片可以坏上几个星期,而所有存活探测、错误率看板、「服务健康吗」的问答统统是绿的。真正描述这次故障的状态码,由放弃等待的那一层写下,指向的是一个应用从未收到的请求。 + +而最响的那条证据恰好在误导:`SyntaxError`。它对文档的描述是准确的,对原因的判断是错的。 + +--- + +## 隐藏变量:空闲时 13.6 秒,满载时 2507 秒 + +修之前,审计的耗时看起来像个常数。它不是。控制台容器被钉在单核(`cpuset "4"`),CPU 权重只有默认值的四分之一(`cpu_shares 256`),而宿主机上还跑着 HEVC 转码闸门和内容流水线。这场审计要跟别的任务抢那一个核。 + +把审计挪出请求路径之后,应用开始自己记录耗时,假设才变成测量: + +``` +automation counts refreshed in 76.1s +automation counts refreshed in 82.3s +automation counts refreshed in 93.5s +automation counts refreshed in 124.2s +automation counts refreshed in 165.1s +automation counts refreshed in 633.6s +automation counts refreshed in 1282.5s +automation counts refreshed in 2507.2s +``` + +同一份代码、同一个 341 篇的库,一个数量级的跨度,最坏值比空闲机上量到的数字**慢 184 倍**。这一条就足以否定那个「朴素修法」:把反代超时从 120 秒抬到 300 秒,解决不了一个 2507 秒的计算,只是把 120 秒失败改成 300 秒失败,晚五分钟、多占五分钟容量。 + +这就是我在边缘层反复吃到的教训:**端点延迟不是代码的属性,而是它被允许借用多少 CPU 的属性。** 单核 cgroup 限流会把一次 O(n) 文件审计变成耗时由邻居决定的无界操作。 + +--- + +## 修法:把读路径和计算路径彻底拆开 + +三处契约改动,每一处都小到可以独立验证。 + +**一、读路径绝不计算。** 计数放进 TTL 缓存(300 秒,`AUTOMATION_COUNTS_TTL_SEC` 可调)。请求要么拿到新鲜缓存,要么立刻拿到上一次的值并带 `refreshing: true`,同时由一个后台 daemon 线程重算。单飞用一把锁加一个标记守住,成串的轮询不会变成成串的审计。缓存还会在应用 lifespan 里预热一次,第一个打开面板的人不用替所有人付这笔账。 + +
+🔍 查看 stale-while-revalidate 实现 —— 缓存、单飞刷新线程与启动预热(52 行) + +```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): + """面板绝不在审计上阻塞。""" + 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]: + """慢的那一半:全库缺封面 + 过期朗读目标。""" + 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: # 审计坏了也不能把卡片打成 500 + 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() +``` + +
+ +**二、陈旧值必须自己说出来。** 响应里带 `refreshing` 与 `cached_at`,卡片在重算期间把「计算中…」挂在数字后面,3 秒后自动补取一次。反面做法——在数据到达前先显示 `0`——比慢更糟,因为积压计数器上的 0 与「确实没事可做」长得一模一样。 + +**三、客户端绝不盲解析。** 真正躺在浏览器侧的缺陷是 `res.json()`:它把「收到了 HTML 错误页」推断成「JSON 格式错误」,顺手丢掉了状态码和本该指出故障层级的那段正文。 + +
+🔍 查看「先读文本」的响应读取器与重试契约(22 行) + +```javascript +function autoReadJson(res) { + // 反代超时 / 502 / 503 回的是 HTML 错误页;裸 res.json() 会把它变成 + // "Unexpected token '<'",并把「哪一层挂了」这条信息彻底吞掉。 + 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 || '空响应') }; + } + }); +} + +// refreshing 时:先显示上一次的数字并标注状态,3 秒后再来一次 +document.getElementById('auto-counts').textContent = + '笔记 ' + counts.notes + ' · 缺封面 ' + counts.missing_cover + + ' · 待朗读 ' + counts.tts_stale + (data.refreshing ? ' · 计算中…' : ''); +if (data.refreshing) { autoRetryTimer = setTimeout(window.autoReload, 3000); } +``` + +
+ +第四处改动更小但很关键:`?refresh=1` 强制重算。测试和人一样,都需要一条「立刻要新数字」的通道,否则整套测试只能靠 sleep 等 TTL 过期。 + +测试契约跟着端点一起改了,这是大多数人会跳过的一步:用例现在轮询到后台线程落地为止,而不是对第一次响应就断言数字。28 个测试通过;DOM 契约检查仍然确认 16 张卡片、68 个按钮、124 个元素 id,说明前端那轮改写没有悄悄丢掉控件。 + +--- + +## 复盘:我此后对每个面板端点都套用的五条规矩 + +1. **看板端点不是查询接口。** 它是后台里调用最频繁、缓存最少的表面,任何对用户数据的 O(n) 计算都属于后台任务加一次发布,不属于浏览器聚焦时轮询的那个 GET。 +2. **超时预算等于最小那一跳,不是各跳之和。** 四层反代各自「允许」120 秒并不叠加:请求死在最先到期的那个时钟上,而应用一个都看不见。应用侧的预算必须比最小的那个还低一个数量级。 +3. **`499` 值得配一条告警。** 它是唯一一个表达「调用方放弃了」的信号,且天然绕开所有基于 5xx 的监控。从访问日志抓单条路径上 499 与 200 的比值,几乎零成本覆盖这一整类故障。 +4. **在反代后面永远不要信 `res.json()`。** 先读文本,留下状态码,留下第一行。下一个接手的人手里只有这条错误信息。 +5. **stale-while-revalidate 优于一个更大的数字。** 抬高超时只是把墙往后推;把计算与读拆开才是把墙拆掉——而且拆掉的是你还没量到的那个最坏值(这里的 2507 秒),不只是量到的那个(13.6 秒)。 + +--- + +## 交付结果 + +- **缓存读路径:连续五次容器内实测 200,0.24–0.36 秒**,中位 0.26 秒,对照它所替代的 13.6 秒审计——**中位快 52 倍,整个区间快 38–57 倍**。 +- **最坏情况不再暴露给使用者。** 宿主机饱和时审计仍要 2507 秒,但它现在只花掉一个后台线程,而不是一张坏掉的卡片,面板全程照常应答。 +- **正确性保留而非掩盖。** 撰写时的计数:341 篇笔记、1 个缺封面、47 篇待生成朗读——同一个计算,只是搬了位置。 +- **故障再也无法伪装。** 任何一跳再次中断,卡片会打印 `HTTP 503 · …`,排查从正确的层级开始。 + +整件事里最有用的一行,恰恰是那行说谎的 `Unexpected token '<'`。把 HTML 错误页递给一个 JSON 调用方,不是语法错误——那是反代用它唯一会说的语言在告诉你:你的代码还没醒,它就已经不等了。