fix(quota): 十三个退避中的源被渲染成「有数据」

## 症状

生产 Quota 页面上 17 个源里 13 个显示「有数据」徽标 —— 而它们实际上什么都
没返回,只是刚刚被退避跳过。

## 根因(三层,逐层都静默)

**① 退避记录缺 `skipped` 标记**
`due_sources` 与 `collectSource` 返回退避记录时没有 `skipped = true`,页面的
`filter(r => !r.skipped)` 就把它们当正常记录放进卡片。卡片渲染匹配已知字段,
一条只有 `reason` 的记录什么都匹配不上,于是落到兜底分支。

**② 兜底分支文案是「有数据」**
那是个断言 —— 它在说「探测有应答」。落到这里的记录其实是:一条退避通知、
一种我们不认识的错误形状、或供应商改了响应格式。把「没有证据」说成「有数据」,
十三个死上游就被渲染成健康的了。改成「未探测」。

**③ 退避与「没有余额接口」被混为一谈**
页脚原本统一说「未暴露余额接口」。这是对上游的另一种断言,而且对死上游是错的
—— 它有接口,只是暂时没探到。

## 「只改了一处」这个坑

`render()` 改成按 `backing_off` 标记分组之后,两个调用点都必须传**记录**而不是
名字。我先改了刷新路径就部署,结果是页脚显示「未列出 13 个源:、、、……」——
症状看起来像渲染 bug,实际是两个调用点与同一个函数之间的契约不一致。

快照路径那行 `.map(function(r){ return r.name; })` 是后来才补的。
判据 `TestQuotaRenderCallSitesPassRecordsNotNames` 现在要求两处都在。

## 顺带修掉的两个真 bug

**空 Lua 表在 Go 侧解码成 `{}` 而不是 `[]`**
`plan.backing_off || []` 里 `{}` 是 truthy,空表会原样进入 `out.concat()` 并抛错,
整次刷新报失败。改用 `Array.isArray` —— 它与插件实际返回的类型匹配。

**分组靠文案匹配是个定时炸弹**
原先用 `reason.indexOf("backing off") === 0` 判断退避。理由字符串一旦本地化,
退避就会被并进「未暴露余额接口」。改用显式 `backing_off: true` 标记,
判据里也改成按标记匹配 —— 变异把文案改回英文立刻变红。

## 判据

新增两条:
- `TestQuotaBackoffRecordIsSkippedAndFlagged` —— 退避记录必须带 `skipped` 和
  `backing_off` 标记,且必须有 reason(否则卡片是空的)
- `TestQuotaRenderCallSitesPassRecordsNotNames` —— 两个调用点都不得把跳过列表
  映射成名字

变异自证四次全部变红:去掉 `skipped`、去掉 `backing_off`、文案改回英文、
快照路径改回 map-to-name。

## 生产实测(浏览器真实点击)

```
第1轮 1694ms: 4 个源 · 0 个读取失败
        13 个源正在退避(连续探测失败,等下次重试):trae、qijiar、toter、…
第2轮 2488ms: 4 个源 · 0 个读取失败
        13 个源正在退避(…):trae、qijiar、toter、…
```

页面现在只列 4 个真有数据的源,13 个退避中的归到页脚并说明原因。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-05 10:27:04 +08:00
parent 4826837f9c
commit 86ba4f57a0
2 changed files with 155 additions and 18 deletions

View File

@ -389,8 +389,8 @@ local function collectSource(src, budget)
-- A streak is a host that has failed repeatedly. Skip it outright until its
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
if backoffSeconds(seen, known.streak) then
return { name = src.name, host = host, skipped = true,
reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
return { name = src.name, host = host, skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
@ -577,8 +577,12 @@ PLUGIN.due_sources = function(payload)
local base = (s.base_url:gsub("/+$", ""))
local known = HOST_MEMO[hostOf(base)]
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
backing[#backing + 1] = { name = s.name, host = hostOf(base),
reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
-- skipped matters: the page filters on it. Without the flag a backed-off
-- host reaches the card renderer, which has no field to match and falls
-- through to its "has data" default — so thirteen dead upstreams were
-- displayed as if they had answered.
backing[#backing + 1] = { name = s.name, host = hostOf(base), skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
else
due[#due + 1] = s
end
@ -824,7 +828,14 @@ PLUGIN.ui = {
}
if(r.hard_limit!==undefined && r.hard_limit!==null)
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
return {cls:"", pill:'<span class="qa-pill">有数据</span>'};
// Nothing here matched. Say so.
//
// The old default was a neutral "有数据", which is a claim: it says the probe
// answered. A record that reached this point answered with no field the page
// knows — a back-off notice, an error shape we do not recognise, or a vendor
// that added a new response. Calling that "has data" is how thirteen dead
// upstreams ended up rendered as healthy. Absence of evidence is not evidence.
return {cls:"unknown", pill:'<span class="qa-pill">未探测</span>'};
}
function card(r){
var st=state(r);
@ -837,6 +848,13 @@ PLUGIN.ui = {
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
return h;
}
// A record with nothing but a reason has no balance, no month, no windows —
// the card renders as a bare header with an empty body. Say why instead.
if(r.reason && !r.error && (r.balance===undefined||r.balance===null)
&& (r.hard_limit===undefined||r.hard_limit===null)){
h+='<div class="qa-fail">'+escH(r.reason)+'</div>';
return h+'</div>';
}
h+=balLine(r);
var rows="";
rows+=month(r);
@ -861,7 +879,10 @@ PLUGIN.ui = {
var rs = (st && st.reports) || [];
if(!rs.length) return false;
var real = rs.filter(function(r){ return r && !r.skipped; });
var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; });
// Records, not names — same reason as the refresh path. Both call sites were
// mapping to names while render() was changed to group on the flag; fixing
// one and shipping it is what produced a footer with thirteen blank entries.
var skipped = rs.filter(function(r){ return r && r.skipped; });
render(real, skipped);
var sub=document.getElementById("qa-sub");
if(sub && st.updated_at){
@ -878,10 +899,22 @@ PLUGIN.ui = {
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
} else {
host.innerHTML=real.map(card).join("");
if(skipped && skipped.length){
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</div>';
}
if(skipped && skipped.length){
// Two reasons a source has no card, and they are not interchangeable.
// "This upstream exposes no balance endpoint" and "this upstream is
// backing off after failing" are different facts; collapsing them into
// one list tells the operator their dead host simply has no dashboard.
var backing = skipped.filter(function(r){ return r.backing_off===true; });
var silent = skipped.filter(function(r){ return r.backing_off!==true; });
if(backing.length){
host.innerHTML+='<div class="qa-skip">'+backing.length+
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
}
if(silent.length){
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
' 个源(未暴露余额接口):'+escH(silent.map(function(r){return r.name;}).join("、"))+'</div>';
}
}
}
var sub=document.getElementById("qa-sub");
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
@ -924,8 +957,12 @@ PLUGIN.ui = {
// re-probed on every refresh forever. Measured before this split: fanout left
// the memo at 0 entries and every refresh cost the full 12s.
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
var due = (plan && plan.due) || [];
var out = (plan && plan.backing_off) || [];
// An EMPTY Lua table decodes as {} on the Go side, not [] — and {} is truthy,
// so `|| []` does not replace it. An empty object reaching out.concat()
// throws, and the whole refresh reports a failure. Array.isArray is the check
// that matches what the plugin actually returns.
var due = (plan && Array.isArray(plan.due)) ? plan.due : [];
var out = (plan && Array.isArray(plan.backing_off)) ? plan.backing_off : [];
if (due.length) {
out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){
@ -946,8 +983,10 @@ PLUGIN.ui = {
await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){
if(!r) return null;
// A host that is merely BACKING OFF has not just failed; recording it as a
// failure would grow the streak on a probe that never happened.
if(r.reason && String(r.reason).indexOf("backing off")===0) return null;
// failure would grow the streak on a probe that never happened. Keyed on the
// flag rather than the wording, so translating the message cannot silently
// turn every skipped host into an ever-growing streak.
if(r.backing_off===true) return null;
if(r.error) return {host:r.host};
if(r.skipped) return {host:r.host};
return {host:r.host, vendor:r.vendor};
@ -959,7 +998,11 @@ PLUGIN.ui = {
}
var all=out||[];
var real=all.filter(function(r){return !r.skipped;});
var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
// The records, not their names — render() groups skipped sources by the
// backing_off flag, which a bare name cannot carry. This call site was left
// mapping to names while the other was fixed, and the mismatch showed up as
// a footer listing thirteen unnamed sources.
var skipped=all.filter(function(r){return r.skipped;});
// real may already contain errors; collect_all keeps them for visibility.
render(real, skipped);
if(btn) btn.disabled=false;