CC Switch 的 keep-last-good:查询失败不抹掉上次成功值

2026-08-31

额度这类数字,背后是一次网络请求。请求成功就更新、失败就换成「查询失败」,这是最直觉的写法,也是最容易出问题的写法:上游抖一下,数字就没了,等下一轮回来又自己长回来。反过来把失败一律掩盖掉也不行——用户把 Key 换了、删了、过期了,展示出来的还是一个早就不成立的数字,而且他没有任何线索知道这个数字已经不算数了。

这两个方向的坑,CC Switch 在 src/lib/query/queries.ts 里用一百多行纯函数绕开了。这篇就拆这一百多行。

本文依据的是仓库快照 3217f725(仓库内版本号 3.20.1),核对日 2026-08-31。全程只是静态读源码和文档,没有编译、没有运行,也没有安装过这个桌面应用,所以下面出现的一切都是「代码里写着什么」,不是「用起来是什么样」。

这段逻辑住在哪一段

src/lib/query/queries.ts 的第 104 行到第 243 行,一整块,注释密度比代码还高。核心是三样东西:

  • 窗口常量 KEEP_LAST_GOOD_MS = 10 * 60 * 1000,在第 120 行;
  • 失败分类函数 isTransientUsageError,第 142 到 167 行;
  • 决策函数 resolveDisplayUsage,第 192 到 243 行。

后两个都是纯函数。resolveDisplayUsage 的签名里把 now 当参数注入(第 196 行),不在函数体里读时钟——这样窗口过期这件事才可能被写成测试。对应的测试文件是 tests/lib/keepLastGoodUsage.test.ts(文件确实存在,我们没有逐个用例核过)。

需要先说清楚口径:10 分钟这个窗口是 v3.20.1 源码里写死的常量,调用方也可以在调用时传入自己的窗口长度。它是代码里的默认配置,不是对实际表现的承诺,也可能随版本变。

第一步:把失败分成两类

isTransientUsageError 判断一次失败是不是「瞬时的」。它用的是白名单,三条规则依次走:

第一条是错误文案匹配(第 148 到 156 行),命中 network errorrequest failed请求失败failed to read response读取响应失败 之一就算瞬时。中英各一套不是冗余,注释说明了原因:原生路径和 JS 脚本路径产生的错误文案本来就不同,两边都得兜。

第二条是状态码。用正则从错误文本里抓第一处 http\s+(\d{3})(第 160 行),5xx 与 429 判为瞬时,其余 4xx 判为确定性(第 163 行)。这个切分很关键:限流和服务端错误是「等一会儿再来就行」,而 401、403、404 是「你的配置本身有问题」,两者的正确处置方向相反。

第三条是兜底:任何没被上面两条识别的错误,一律 return false(第 166 行)。注释把这条明确称作失败安全的设计——宁可把一次本可掩盖的抖动误报成失败,也绝不误掩盖一次确定性失败。这个默认方向选得很克制,因为掩盖的代价是不可见的,误报的代价至少用户看得见。

顺带说一句这套做法的脆弱处:它靠 includes 匹配后端返回的错误字符串来分类,后端改一个字文案就会失配。同样的手法在这个仓库里不止一处,src/lib/api/model-fetch.ts 第 94 到 121 行按错误串给取模型失败分流,用的是同一种「文案即协议」。这是事实描述,不是评价。

第二步:一张六行的决策表

resolveDisplayUsage 接收四个输入——本次的原始结果、react-query 给的 dataUpdatedAt、上一份成功快照、当前时刻——返回三个东西:要展示的 data、要展示的「查询时刻」lastQueriedAt、以及回写用的新快照 lastGood

情形展示的 datalastQueriedAt快照 lastGood
成功本次结果本次更新时刻刷新为本次(第 220-222 行)
确定性失败本次失败原样透出本次更新时刻置空(第 223-227 行)
瞬时失败,快照在窗口内旧的成功值那次成功的时刻保留
瞬时失败,超窗或无快照本次失败原样透出本次更新时刻保留
reject 且窗口内陈旧的成功值那次成功的时刻用陈旧值补种(第 205-212 行)
reject 且超窗置空,交调用方合成占位那次成功的时刻保留不清空(第 213-217 行)

这张表要横着读才有意思。data 那一列是「显示什么」,lastGood 那一列是「记住什么」,两者的取值并不同步。

先看第二行。确定性失败为什么要顺手把快照置空?注释写得很直白:旧的成功快照已经不可信了,如果留着它,随后一次网络抖动被判成瞬时失败时,那份「配置或鉴权已失效」的旧额度就会被重新复活一次。也就是说,清空快照不是为了这一次,是为了堵住下一次。

再看第三行的 lastQueriedAt。窗口内继续展示旧值时,它指向的是那次成功的时刻,而不是刚刚这次失败的时刻。这样上层拿去渲染相对时间,会自然地一路走到「10 分钟前」然后越过窗口过期,不需要再单独埋一个计时器。

失败路径不是空操作

这是这段代码最容易被写错的地方,也是我想单拎出来讲的一点。

useUsageQuery 里(queries.ts 第 279 到 305 行),快照存在一个 useRef 里,每个 hook 实例各持一份。调用完 resolveDisplayUsage 之后的那次 ref 写回,是写在所有分支之外的,无条件执行

如果按直觉写,回写快照这件事只会挂在成功分支上——失败了就什么都不做,等下次成功再更新。但上面那张表说明失败路径上要做的事情反而更多:确定性失败要清空、瞬时失败要保留、reject 且窗口内要用陈旧数据反向补种一份快照出来。三种失败,三种不同的写回动作,一个都不能省。把回写放进 if (success) 里,前面那些注释里写清楚的语义会一条不剩地丢掉。

补种那一条尤其反直觉。reject 分支里,lastGood 是从 react-query 保留的陈旧 data 现场造出来的,时间锚定 dataUpdatedAt。注释解释了动机:这样快照是从查询缓存派生的,组件重新挂载、useRef 归零之后,窗口判定依然成立。

同一个仓库里还有一处同类思路,写在 Skills 安装那边(src/hooks/useSkills.ts 第 87 到 100 行):onSuccess 直接改本地已安装列表,而 onSettled 无论成败都去失效权威列表。注释给的理由是后端可能已经落库、只是 live 配置同步失败了。这两处的共同点是同一个:失败不等于什么都没发生,缓存该动还得动。

reject 为什么要单开一条分支

resolveDisplayUsagerejected 是个独立入口,不走后面那套 success 判断。原因在注释里:后端已经把纯传输层失败(发送失败、超时、读响应中断)转成 Err,走 invoke 的 reject 路径,不再折叠成 success: false 的正常返回。这类失败到不了 isTransientUsageError 手里。

麻烦在于 react-query 在查询失败时保留的是上一次成功的 data。如果不管它,这份陈旧数据会被当成新鲜成功无限期展示下去。注释里给了那句真正点题的话:否则「彻底断网」反而会比「单次 5xx」掩盖得更久。所以 reject 分支被套进同一个窗口,超窗之后把 data 置空,交给调用方合成失败占位。

但超窗时快照不清空(第 213 到 215 行),理由是 reject 本身属于瞬时失败,不代表旧值失信——这和 5xx 的处置保持了一致。判断「要不要继续展示」和判断「旧值还可不可信」是两件事,这段代码把它们分开了。

两个消费点,和一个 scopeKey

同一套策略被两处复用:

  • useUsageQueryqueries.ts:279-305):超窗且 isError 时合成一个 {success:false, error} 占位对象,好让上层拿得到一个明确的失败态和重试入口,而不是拿到 undefined 之后无从处置。
  • useQuotaKeepLastGoodsrc/lib/query/subscription.ts:47-77):多带一个 scopeKey

scopeKey 值得单说。它标识这次查询的身份——应用 id,或者绑定的托管账号 id。身份一变,第 55 到 57 行直接把旧快照整份丢掉。理由不难想:快照是「上一次成功」,但账号换了之后,上一个账号的额度不能拿来掩盖新账号的失败,那是两回事的数据。占位对象 QUERY_REJECTED_PLACEHOLDER 定义在同文件第 24 到 33 行。

一个纯函数被两个 hook 复用,其中一个还要额外加一层身份隔离——这也解释了为什么 resolveDisplayUsage 要做成纯函数、把 now 注入进来,而不是直接写在 hook 里。

放回全站的刷新节律里看

这套策略并不是全站通用的。把 src/lib/query/ 下各个查询的刷新配置摆在一起看,会发现节律是按数据性质分档的(以下均为 v3.20.1 的代码默认配置,可配、且会随版本变):

查询位置刷新配置
代理服务状态proxy.ts:28条件轮询:服务在跑才 2 秒一轮,否则关掉
供应商健康 / 熔断统计failover.ts:23:945 秒
供应商列表queries.ts:62-67代理运行时 10 秒,不运行则关轮询
用量看板usage.ts:10统一 30 秒常量
订阅额度subscription.ts:125 分钟,且仅在自动查询开启时才轮
已安装 SkillsuseSkills.ts:29-30staleTime: Infinity,只有手动刷新才重拉

全局默认值在 src/lib/query/queryClient.ts 第 3 到 14 行,只有四条:查询 retry: 1refetchOnWindowFocus: truestaleTime: 0,写操作 retry: false。默认 staleTime 是 0 意味着任何重新挂载或窗口聚焦都会触发后台重取,上面那些配置全都是在这个基准上按需覆写出来的。

keep-last-good 只长在最慢的那一档——用量与额度这两支。从代码分布上看是这样,我们不去替作者补充动机。

另外补一条版本口径:故障转移那三个查询(useProviderHealthuseFailoverQueueuseAutoFailoverEnabled)在 v3.19.2 时只要拿到 providerId 和 appType 就会一直轮,v3.20.1 统一加上了 enabled 门控参数,轮不轮改由调用方决定。改动后的代码在 src/lib/query/failover.ts:14-25:103-109:195-203

想自己核对的话

最小路径是三步:打开 src/lib/query/queries.ts 直接跳到第 120 行看常量,往下读第 142 到 167 行的白名单,再读第 192 到 243 行的决策函数。这两个函数的注释本身就是设计文档,比任何转述都准。想看它怎么被用,再跳 subscription.ts 第 47 到 77 行对比一下 scopeKey 那几行的差别。

最后提醒两句边界。第一,上面所有秒数、分钟数都是源码里的默认配置,不是对实际运行结果的保证,别拿它们去推算「会不会掉线」「多久能恢复」。第二,这套策略的直接后果是:窗口内被交给上层展示的额度,可能是十分钟前那次成功的值,不是刚才那次请求的值——代码用 lastQueriedAt 把这件事显式暴露了出来,读代码的时候别把这两个时间混起来。

关于这个项目后端分层的读法,我们另有一篇写过,可以对照着看:CC Switch 后端四层架构


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、 路由指南与发布说明,以及 src/src-tauri/tests/ 的源码整理, 核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    在 CC Switch 里加一个国内直连的供应商

    力达云网关,注册送 ¥5 额度,一期提供 DeepSeek。

    去添加

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。