# 错误处理说明 一句话原则:**路由器只按 `ErrorKind` 决策,永远不解析错误文案。** --- ## 1. 错误分类表 全部错误先被压成这 10 个稳定分类(`src/core/errors.ts` 的 `classify()`): | `ErrorKind` | 什么时候 | 换号 | 直接重试 | 账号降权 | |---|---|---|---|---| | `auth` 鉴权失效 | 401 / 403(非额度语义)、`invalid_api_key`、`token expired`、`登录已过期` | ✅ | ❌ | 10 分钟起,阈值 ×3 后**判死** | | `rate_limit` 限流 | 429(非额度语义)、`too many requests`、`请求过于频繁` | ✅ | ✅ | 取 `Retry-After` 与 300 秒的较大者 | | `quota` 额度耗尽 | 402、`insufficient_quota`、`余额不足`、`credits exhausted` | ✅ | ❌ | 30 分钟起 | | `server` 上游故障 | 5xx | ✅ | ✅ | **不降权**(不是账号的问题) | | `network` 网络异常 | 连接失败、超时、408/504、`status === 0` | ✅ | ✅ | **不降权** | | `bad_request` 请求不合法 | 400/405/409/413/422 | ❌ | ❌ | 不降权 | | `not_found` 接口不存在 | 404 | ❌ | ❌ | 不降权(多用于能力探测) | | `unsupported` 上游不支持 | 适配器判定(例如 OpenCode 的 `FreeTierError`) | ❌ | ❌ | 不降权 | | `none` 正常 | 2xx 且无错误语义 | — | — | — | | `unknown` 认不出 | 兜底 | ✅(保守) | ✅ | 不降权 | **判定的顺序是有讲究的**:状态码是硬事实,先按它定基线;然后 403/429/**4xx** 再细看文案 —— 因为有的上游把「额度耗尽」塞在 403、把「余额不足」塞在 429 或 400 里。 只按状态码分类会把「换个号就能好」的额度问题误判成「请求本身错了」。 ### 一个必须单独认的例子:OpenCode 的 `FreeTierError` `FreeTierError` 是 **403**,按状态码会被归成 `auth` —— 那就会让路由器**去换账号**, 而换任何一个账号都是同一个错(它校验的是**请求形状**),纯属白烧其它账号的额度。 所以适配器里单独认它,归成 **`unsupported`**(不换号、不重试、不降权), 并把「过门要哪三件事」一次列清: ``` 上游把这次请求判成了「非 OpenCode 客户端」。免费层要求三件事: ① 流式(stream:true)② 请求里有一个叫 read 的工具 ③ 规范的 x-opencode-session 会话头。 插件在「账号没有 key」时会自动补齐这三样;如果你看到这条,通常是因为: (a) 上游又改了门禁形状;(b) 配置里 upstream.freeTierMode 被设成了 never; (c) 你要的是付费模型 —— 付费模型在免费通道上会 401 Missing API key,需要真 key。 ``` ### 还有两个「换号也解决不了」的例子(匿名通道专用) 匿名(免费层)通道上,**同一个 401 有两种完全不同的含义**,认错就会把用户引偏: | 上游 | 错认成 | 正确 | 用户该做什么 | |---|---|---|---| | `401 AuthError: Missing API key` **且这次是匿名请求** | `auth` → 换号,界面显示「API 密钥无效」 | **`unsupported`** | 换一个**免费**模型,或者填真 key | | `403 RegionError: not available in your country` | `auth` | **`unsupported`** | 换一个模型(地区限制,不是你配错了) | | `401 AuthError: Invalid credential`(**带 key** 时) | `unsupported` | **`auth`** | 这个确实该换号 / 重填 key | 所以 `describeOpenCodeError(status, body, { anonymous })` 把「这次是不是匿名请求」当**输入**, 流式路径与路由器共用它。 实测踩到的:选了一个免费层不覆盖的模型 → 上游 401 → 老逻辑归成 `auth` → DSH 界面显示成「**API 密钥无效**」,看起来像账号坏了,其实账号根本没参与鉴权。 同一条规矩适用于任何「换个账号也一样」的事,例如上游明确不支持某个模型的图片输入。 判据一句话:**换号能解决 → `auth`/`quota`/`rate_limit`;换号解决不了 → `unsupported`/`bad_request`。** ## 2. 换号规则 `src/core/router.ts` 的 `execute()`: ``` 候选顺序 = 可用的(按策略排序) + 降权中的(垫底,实在没得选才用) for attempt in 1..maxAccountAttempts: 取候选里的下一个账号 跑一次 成功 → markOk() → 返回 失败 → 记 attempts[] → 视情况 markFailure() shouldSwitchAccount(kind) 或上游故障 → 继续下一轮 否则(bad_request 等)→ 立刻返回失败 ``` 三个关键点: 1. **每次尝试都会换一个账号**(用 `exclude` 记录已试过的,不会拿同一个号试两次); 2. **失败分类决定要不要继续**:`bad_request` 换号也是同一个错,所以直接返回 —— 省下的是「把其它账号的额度浪费在一个必然失败的请求上」; 3. **上游 5xx / 网络错误也允许换号**,但会**先睡一小会儿**(100ms + 250ms × 尝试次数), 免得把已经不稳的上游打得更死。 ### 「还没往 DSH 吐字」才换号 流式请求最容易出问题的地方是:**已经吐了半句话,这时候才收到 401**。 本插件的做法是在 `pump()` 里**先取第一个事件**: - 第一个事件就是 `error` → 这时候还没往 DSH 吐任何 chunk → **静默换号**, 用户完全看不到; - 第一个事件是正常内容 → 说明上游接受这次请求了,后续再出错就如实报给 DSH (换号会导致内容错乱,不能换)。 ## 3. 账号降权与恢复 | 阶段 | 行为 | |---|---| | 计数 | 每次「账号自己的问题」(auth / quota / rate_limit)失败 +1;成功立刻清零 | | 降权 | `failures >= degradeAfterFailures`(默认 2)→ `state: 'degraded'`,`weight` 上升 | | 排序 | 降权账号**永远排在真正可用的后面**,且内部顺序固定(不打乱观察面) | | 恢复检测 | 调度器按 `gateway.recoverIntervalSec`(默认 600 秒)对**降权中的**账号做一次最便宜的探测(优先额度接口,没有就验凭证) | | 恢复成功 | `markOk()` —— 失败计数清零、降权立刻解除、重新参与路由 | | 恢复失败 | 再降一轮权(避免对着死号每轮都打上游) | | 判死 | 鉴权失败次数达到 `degradeAfterFailures × 3` → `state: 'invalid'`,不再参与路由,日志 error | | 手工 | 页面上的「重置健康」或工具 `multiagent_routing {action:'reset'}` | **为什么要「固定顺序垫底」**:如果降权账号还在轮询池里参与排序,它们会不断被选中、 不断失败、再降权 —— 形成一个自我强化的坏循环。垫底 = 只在「其它号全不可用」时才兜底。 ## 4. 限速与并发(防上游风控) 三层保护: | 层 | 位置 | 作用 | |---|---|---| | **令牌桶** | `TokenBucket`,**每个账号一个** | 把「每分钟 N 次」(`rateLimitPerMinute`,默认 60)变成硬上限。新桶只给 1/4 存量,避免插件一启动就爆发 | | **并发闸门** | `Semaphore`,`tasks.concurrency` | 活动任务同时最多几个账号在跑(默认 **1**,串行最稳) | | **启动错峰** | `startupDelaySec` + 账号序号 | 签到间隔 5 秒、额度 3 秒错开,避免所有账号在同一秒打上游 | ## 5. 活动任务的失败处理 ``` fetchAvailableTasks() 抛错 / 返回空 ├─ 抛错 → 该账号 failures +1,记一条 '(fetch)' 流水,可能触发暂停 └─ 空 → 不是故障。返回「没有待做的任务」并只记 debug executeTask() ├─ 成功 → 记 credits,写幂等键(今天不会再领),failures 清零 └─ 失败 → 记流水 → 可重试且配了 retryDelaySec → 等一会儿再试一次 → 仍失败 → failures +1 → failures >= maxFailures → **只暂停这一个账号** + 日志 error ``` 暂停是**账号级**的,不是 Agent 级 —— 一个号挂了不会影响同 Agent 下其它号。 恢复方式:页面上的「恢复」按钮,或工具 `multiagent_tasks {action:'resume'}`。 ### 幂等 | 模块 | 幂等键 | 效果 | |---|---|---| | 每日签到 | `checkin:::` | 同一天只签一次(DSH 重启也不重复) | | 活动任务 | `task::::` | 同一个任务同一天只领一次 | 键存在 `state.json` 的 `schedule` 表里,所以**重启不会漏也不会重复**。 「立即签到」按钮是唯一会忽略幂等键的入口(故意如此,方便手动补签)。 ## 6. 定时任务自身的熔断 调度器(`src/core/scheduler.ts`)按 job 独立记账: - 连续失败 **5 次** → 该 job `paused: true`,写一条 error 日志; - 冷却 **10 分钟**后自动恢复(失败计数清零,记一条 info); - 暂停状态、失败次数、上次结果/错误都会出现在配置页的「定时任务」表里。 一条 job 抛错**不会**影响其它 job(每条独立 try/catch)。 ## 7. 适配器里「不是故障」的三种结论 这三种情况**必须**如实返回,绝不能伪造成功,也不能记成失败: | 结论 | 返回 | 记账 | |---|---|---| | 上游没有这个接口 | `{ supported: false, message: '…(这不是故障)' }` | 不算失败、不降权、不暂停 | | 凭证不够权限(如 Inference-only key 查额度) | `quota.error = '这个 key 没有「All」权限…'` | 不算失败 | | 上游明确不支持该请求(如该模型不接受图片) | `ErrorKind: 'unsupported'` | 不降权、不重试 | 理由:**它们永远不会成功,把它们记成失败只会让正确的功能被熔断掉**, 而用户看到的却是「莫名其妙暂停了」。 ## 8. 流式传输的稳定性 「SSE 不能中断」的实现要点(`src/core/sse.ts`): | 风险 | 处理 | |---|---| | 一个 SSE 事件被 TCP 切成多段 | 增量解码器按 `\n\n` 切、跨 chunk 缓冲残片 | | 上游用 `\r\n` 行尾 | 先归一成 `\n` 再切 | | 心跳 / 注释行 | `:` 开头的块直接跳过 | | 单条事件是坏 JSON | **只跳过这一条**,并把跳过次数记进日志,绝不终止整个流 | | 上游没发 `[DONE]` | 流结束时把缓冲里的残片冲出来 | | 上游没给 `finish_reason` | 兜一个 `finish: stop`,DSH 侧才能收尾 | | 用户取消 / 超时 | `signal` 贯穿到 `fetch` 与读取循环,不会留悬挂连接 | | 已经吐了内容才收到错误 | 同 §2 —— 不换号,如实把错误报给 DSH | ## 9. 那些「静默失败」被特意堵住的位置 | 位置 | 如果不专门处理会怎样 | 现在的做法 | |---|---|---| | `Config` 没放进 `export default` | 设置页**静默消失**、零报错 | 显式 `export default { name, inject, apply, Config }`,并有单测锁住 | | Remote 端点的 `source: 'json'` 漏了 | 表现是「没参数的端点正常,一按按钮就报错」 | 客户端契约测试**逐参数断言** `source === 'json'` | | 保存配置时路径不是 volatile | 宿主**整批拒绝**(连合法字段一起丢),界面只有一句「保存失败」 | 界面把「改过哪些路径」列出来;并有测试比对 schema 的真实 volatile 路径 | | 适配器少实现一个方法 | 第一次调用才报 `not a function` | 装载阶段 `validateAdapter()` 一次性列出缺的方法名 | | 模型目录拉不到 | 选择器静默空掉,用户以为插件坏了 | 回退到落盘缓存 + 日志 + 界面「列出模型」显示真实报错 | | 额度拉不到 | 显示 `0` 让用户以为没额度了 | 显示「未知」+ 一句人话原因 | | 时间表达式写错 | 任务「永远不跑」,界面上看不出异常 | `parseSchedule()` 直接抛错并写日志 | | 账号文件解不开(换机/删了主密钥) | 账号列表空着,没人知道为什么 | 日志 error + 界面上明确说「账号文件解密失败,需要重新录入」 |