mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
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:
@ -27,9 +27,16 @@ func TestQuotaBackoffIsAppliedInSource(t *testing.T) {
|
||||
t.Error("quota.lua never consults backoffSeconds for a host with a streak — " +
|
||||
"the backoff is computed but not applied")
|
||||
}
|
||||
if !strings.Contains(src, `reason = "backing off after "`) {
|
||||
t.Error("the skip path does not say why it skipped; a silently skipped host " +
|
||||
"looks exactly like a host that simply has no balance endpoint")
|
||||
// The reason is required, but matched on the FLAG rather than the wording:
|
||||
// the message is localised, and grouping by text would merge "backing off"
|
||||
// into "exposes no balance endpoint" the moment someone translates it.
|
||||
if strings.Count(src, "reason = \"连续探测失败 ") < 2 {
|
||||
t.Error("both skip paths (collectSource and due_sources) must carry a reason; " +
|
||||
"a silently skipped host looks exactly like a host with no balance endpoint")
|
||||
}
|
||||
if strings.Count(src, "backing_off = true") < 2 {
|
||||
t.Error("both skip paths must set the explicit backing_off flag; the page " +
|
||||
"groups skipped sources by it, not by the message text")
|
||||
}
|
||||
// The recording guard has to REPLACE the memo, not merge into it. The bug this
|
||||
// guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a
|
||||
@ -554,3 +561,90 @@ func hostOnly(baseURL string) string {
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestQuotaBackoffRecordIsSkippedAndFlagged pins the shape the page depends on.
|
||||
//
|
||||
// Two things went wrong in the same field, and both were silent:
|
||||
//
|
||||
// 1. The back-off record had no `skipped` flag, so the page's
|
||||
// `filter(r => !r.skipped)` let it through to the card renderer. That
|
||||
// renderer matches on known fields, and a record carrying only a reason
|
||||
// matches none — so it fell through to a default that said "有数据".
|
||||
// Thirteen dead upstreams were rendered as though they had answered.
|
||||
// 2. Once it did carry `skipped`, the page grouped skipped sources by reading
|
||||
// the REASON TEXT. Translating that string would have silently merged
|
||||
// "backing off" into "exposes no balance endpoint" — a different claim
|
||||
// about the upstream. The grouping keys on an explicit flag.
|
||||
func TestQuotaBackoffRecordIsSkippedAndFlagged(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
|
||||
// One failure, enough to put the host inside its window.
|
||||
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.Call("quota", "due_sources", map[string]interface{}{
|
||||
"sources": []interface{}{map[string]interface{}{
|
||||
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := out.(map[string]interface{})
|
||||
backing, _ := m["backing_off"].([]interface{})
|
||||
if len(backing) != 1 {
|
||||
t.Fatalf("backing_off = %#v, want one record", m["backing_off"])
|
||||
}
|
||||
rec, _ := backing[0].(map[string]interface{})
|
||||
if rec["skipped"] != true {
|
||||
t.Errorf("back-off record has skipped=%v — the page filters cards on this "+
|
||||
"field, and without it the record is rendered as a source with data",
|
||||
rec["skipped"])
|
||||
}
|
||||
if rec["backing_off"] != true {
|
||||
t.Errorf("back-off record has no explicit backing_off flag; the page groups " +
|
||||
"by it instead of by reason text, so translating the message cannot " +
|
||||
"merge this with 'no balance endpoint'")
|
||||
}
|
||||
if s, _ := rec["reason"].(string); s == "" {
|
||||
t.Error("back-off record carries no reason; the card renders empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaRenderCallSitesPassRecordsNotNames guards a bug that only appeared
|
||||
// because ONE of two identical call sites was fixed.
|
||||
//
|
||||
// render() groups skipped sources by the backing_off flag. A call site that maps
|
||||
// the records to their names first ("skipped.map(r => r.name)") therefore hands
|
||||
// render an array of strings: the flag is gone, every entry falls into the
|
||||
// "exposes no balance endpoint" bucket, and the footer lists N unnamed sources.
|
||||
//
|
||||
// The first fix changed only the refresh path. The snapshot path kept mapping to
|
||||
// names, and the symptom was a footer reading "未列出 13 个源:、、、、…" — which
|
||||
// reads like a rendering bug rather than a contract mismatch between two call
|
||||
// sites of one function.
|
||||
func TestQuotaRenderCallSitesPassRecordsNotNames(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if strings.Contains(src, ".map(function(r){ return r.name; })") ||
|
||||
strings.Contains(src, ".map(function(r){return r.name;})") {
|
||||
// A benign occurrence would be fine, but there is no other reason for a
|
||||
// skipped list to be reduced to names, so treat any as the bug.
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
if strings.Contains(line, "skipped") && strings.Contains(line, ".map(") &&
|
||||
strings.Contains(line, "r.name") {
|
||||
t.Errorf("a skipped list is mapped to names before reaching render(): %s\n"+
|
||||
"render() groups on the backing_off flag, which a name cannot carry",
|
||||
strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both call sites must exist and both must hand over records.
|
||||
if n := strings.Count(src, ".filter(function(r){return r.skipped;})") +
|
||||
strings.Count(src, ".filter(function(r){ return r && r.skipped; })"); n < 2 {
|
||||
t.Errorf("found %d skipped-list filters, want 2 (snapshot and refresh paths)", n)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user