现象
packages/shared/src/providers/resilience.ts 的 FailureKind 分类表漏掉了当前正在被上报的一个错误码 ACCESS_DENIED,于是它被归到 'unknown',而 kind 会进入一条用户/模型可读的英文说明,渲染成自相矛盾的句子:
Provider routing skipped massive after 1 attempt (ACCESS_DENIED; unknown).
ACCESS_DENIED 是 Massive 适配器对 HTTP 403 的响应码,语义是授权/权益不足:
// packages/shared/src/providers/massive/adapter.ts:351
return { code: 'ACCESS_DENIED', message: 'Your plan does not include access to this data.' }
该行为已被既有测试锚定:packages/shared/src/providers/massive/massive.test.ts:283 → [403, 'ACCESS_DENIED']。
复现
新增 packages/shared/src/providers/access-denied-kind.test.ts(4 个用例)。在未修复的 main 上运行:
(fail) classifyFailure — the HTTP 403 codes > classifies the code the Massive adapter emits for 403 as auth
Expected: "auth" Received: "unknown"
(fail) classifyFailure — the HTTP 403 codes > classifies the sibling 403 code as auth
Expected: "auth" Received: "unknown"
(pass) classifyFailure — the HTTP 403 codes > stays non-retryable, like every other auth failure
(fail) failover lineage sentence > names the auth class instead of "unknown"
Expected: "Provider routing skipped massive after 1 attempt (ACCESS_DENIED; auth)."
Received: "Provider routing skipped massive after 1 attempt (ACCESS_DENIED; unknown)."
1 pass / 3 fail
注意第 3 条在修复前后都通过 —— 这不是断言写错,而是如实记录:'auth' 与 'unknown' 都在 RETRYABLE_KINDS 之外,所以本 issue 不改变重试行为,只修正分类与标注。见下方「影响面」。
第 4 条是本 issue 的核心证据:它走 defineCapability 的完整链路,断言的是最终呈现给人/模型的那句话。
根因
三层叠加:
1) 分类表漏项。 resilience.ts:63-106 的 CODE_KIND_TABLE 收录了 AUTH_EXPIRED / UNAUTHORIZED / FORBIDDEN / AUTH_CONFIG / CONFIG_ERROR / INVALID_CREDENTIAL,唯独没有 ACCESS_DENIED。而该表的 docstring 明确承诺:
Exact ProviderError.code → kind table. Codes are upper-snake across the existing adapters; the table covers every code currently emitted plus the obvious synonyms a new adapter might choose.
这条承诺已经被证伪:massive/adapter.ts:351 正在上报它。
2) 关键词兜底也接不住。 classifyFailure 的兜底是按子串嗅探(resilience.ts:126-142):'AUTH' / 'CREDENTIAL' / '401' / '403'。ACCESS_DENIED 大写后不含其中任何一个子串,也没有数字,于是逐条落空,返回 'unknown'。
3) kind 是一等公民,不是内部记账。 它被写进降级血缘并直接插值进说明文本:
// packages/shared/src/providers/router.ts:286
kind: classifyFailure(error.code),
// packages/shared/src/capabilities/define.ts:80-83
const routingLineage = failoverTrail?.map((step) => ({
kind: 'fallback' as const,
description: `Provider routing skipped ${step.providerId} after ${step.attempts} attempt${step.attempts === 1 ? '' : 's'} (${step.code}; ${step.kind}).`,
})) ?? [];
ProviderFailoverStep.kind 在 packages/core/src/provider.ts:132 的类型注释里被定义为「Classified failure kind (shared resilience layer's FailureKind)」—— 也就是说这是对外承诺的分类结果。
这个缺项的直接后果是分类器的契约被架空。分类器 docstring 写明:
routing/retry/breaker decisions always switch on the kind, never on string-matching scattered across call sites.
而现实是 apps/electron/src/main/kernelHost.ts:1968-1993 只能对字符串硬匹配,且匹配了两次:
: error.code === 'ACCESS_DENIED'
? 'partial-failure'
...
error.code === 'ACCESS_DENIED'
? 'permission-limited'
: 'error',
被迫硬匹配的原因正是:分类器无法把 ACCESS_DENIED 与「真正的未知错误」区分开,所以调用方只能绕开它。
修复方案
在 CODE_KIND_TABLE 的 auth / config 分组补入两个 403 家族码:
// auth / config — never blindly retry
AUTH_EXPIRED: 'auth',
UNAUTHORIZED: 'auth',
FORBIDDEN: 'auth',
// HTTP 403 codes: the same authorization class as FORBIDDEN. The Massive
// adapter already emits ACCESS_DENIED for "plan does not include access",
// so it belongs in this table rather than falling through to 'unknown'.
ACCESS_DENIED: 'auth',
PERMISSION_DENIED: 'auth',
AUTH_CONFIG: 'auth',
ACCESS_DENIED 是已证实在上报的码(adapter.ts:351 + massive.test.ts:283)。
PERMISSION_DENIED 是同族 403 同义词,落在该表 docstring 承诺的「obvious synonyms」范围内,与既有的 FORBIDDEN 同级。
不做的事:不改 classifyFailure 的关键词嗅探分支(保持精确表优先的既有设计);不改 kernelHost.ts 的两处字符串匹配 —— 它正在被 8 个 open PR 触碰,属于独立跟进项,见「影响面」。
影响面
不改变重试与熔断行为。 RETRYABLE_KINDS 只含 timeout / network / rate_limited / upstream,'auth' 与 'unknown' 都在其外,因此:
| 观察点 |
修复前 |
修复后 |
isRetryableKind(...) |
false |
false(不变) |
CircuitBreaker.recordFailure() |
签名不收 kind,与 kind 无关 |
不变 |
failoverTrail[].kind(用户可见) |
'unknown' |
'auth'(修正) |
| 降级血缘说明句 |
(ACCESS_DENIED; unknown) |
(ACCESS_DENIED; auth)(修正) |
也就是说:这是一个分类与标注的修正,不是行为改变。价值有两条且都是实的 —— 对外承诺的 kind 不再错,以及调用方从此可以合法地按 kind 分支而不再需要字符串硬匹配(kernelHost.ts 的两处即为此前的变通)。
关于同文件并发改动(已核查,非冲突):resilience.ts 目前被 #242 改动,但 #242 的 hunk 位于该文件 KEY_FIELDS 附近(@@ -431,6 +431,12 @@),与本 issue 的改动位置(CODE_KIND_TABLE,第 63-106 行)相隔 320 余行、无重叠;#274 改的是缓存命中门禁,同属缓存段。两者与「失败分类表」无语义交集。测试因此新增独立文件,不去改 #242/#274 同时在动的 resilience.test.ts。
验证
TDD 顺序:先写测试 → 未修复代码上 1 pass / 3 fail → 修复 → 4 pass / 0 fail。
# 修复前
1 pass / 3 fail (断言逐一给出 Expected/Received,见上)
# 修复后
4 pass / 0 fail / 4 expect() calls
测试覆盖:
| 用例 |
覆盖内容 |
| 1 |
classifyFailure('ACCESS_DENIED') → 'auth' |
| 2 |
classifyFailure('PERMISSION_DENIED') → 'auth' |
| 3 |
仍不可重试(守护「不引入重试行为变化」这一断言) |
| 4 |
经 defineCapability 走完整链路,断言最终说明句为 (ACCESS_DENIED; auth) |
回归对照:
| 命令 |
修复前 |
修复后 |
bun test packages/shared/src/providers/ --isolate |
123 pass / 0 fail(6 files) |
127 pass / 0 fail / 464 expect() calls(7 files) |
bun test packages/shared/src/capabilities/ --isolate |
44 pass / 0 fail |
44 pass / 0 fail |
bun run typecheck |
5/5 exit 0 |
5/5 exit 0 |
现象
packages/shared/src/providers/resilience.ts的FailureKind分类表漏掉了当前正在被上报的一个错误码ACCESS_DENIED,于是它被归到'unknown',而kind会进入一条用户/模型可读的英文说明,渲染成自相矛盾的句子:ACCESS_DENIED是 Massive 适配器对 HTTP 403 的响应码,语义是授权/权益不足:该行为已被既有测试锚定:
packages/shared/src/providers/massive/massive.test.ts:283→[403, 'ACCESS_DENIED']。复现
新增
packages/shared/src/providers/access-denied-kind.test.ts(4 个用例)。在未修复的main上运行:第 4 条是本 issue 的核心证据:它走
defineCapability的完整链路,断言的是最终呈现给人/模型的那句话。根因
三层叠加:
1) 分类表漏项。
resilience.ts:63-106的CODE_KIND_TABLE收录了AUTH_EXPIRED/UNAUTHORIZED/FORBIDDEN/AUTH_CONFIG/CONFIG_ERROR/INVALID_CREDENTIAL,唯独没有ACCESS_DENIED。而该表的 docstring 明确承诺:这条承诺已经被证伪:
massive/adapter.ts:351正在上报它。2) 关键词兜底也接不住。
classifyFailure的兜底是按子串嗅探(resilience.ts:126-142):'AUTH'/'CREDENTIAL'/'401'/'403'。ACCESS_DENIED大写后不含其中任何一个子串,也没有数字,于是逐条落空,返回'unknown'。3)
kind是一等公民,不是内部记账。 它被写进降级血缘并直接插值进说明文本:ProviderFailoverStep.kind在packages/core/src/provider.ts:132的类型注释里被定义为「Classified failure kind (shared resilience layer'sFailureKind)」—— 也就是说这是对外承诺的分类结果。这个缺项的直接后果是分类器的契约被架空。分类器 docstring 写明:
而现实是
apps/electron/src/main/kernelHost.ts:1968-1993只能对字符串硬匹配,且匹配了两次:被迫硬匹配的原因正是:分类器无法把
ACCESS_DENIED与「真正的未知错误」区分开,所以调用方只能绕开它。修复方案
在
CODE_KIND_TABLE的 auth / config 分组补入两个 403 家族码:ACCESS_DENIED是已证实在上报的码(adapter.ts:351+massive.test.ts:283)。PERMISSION_DENIED是同族 403 同义词,落在该表 docstring 承诺的「obvious synonyms」范围内,与既有的FORBIDDEN同级。不做的事:不改
classifyFailure的关键词嗅探分支(保持精确表优先的既有设计);不改kernelHost.ts的两处字符串匹配 —— 它正在被 8 个 open PR 触碰,属于独立跟进项,见「影响面」。影响面
不改变重试与熔断行为。
RETRYABLE_KINDS只含timeout/network/rate_limited/upstream,'auth'与'unknown'都在其外,因此:isRetryableKind(...)falsefalse(不变)CircuitBreaker.recordFailure()failoverTrail[].kind(用户可见)'unknown''auth'(修正)(ACCESS_DENIED; unknown)(ACCESS_DENIED; auth)(修正)也就是说:这是一个分类与标注的修正,不是行为改变。价值有两条且都是实的 —— 对外承诺的
kind不再错,以及调用方从此可以合法地按 kind 分支而不再需要字符串硬匹配(kernelHost.ts的两处即为此前的变通)。关于同文件并发改动(已核查,非冲突):
resilience.ts目前被 #242 改动,但 #242 的 hunk 位于该文件KEY_FIELDS附近(@@ -431,6 +431,12 @@),与本 issue 的改动位置(CODE_KIND_TABLE,第 63-106 行)相隔 320 余行、无重叠;#274 改的是缓存命中门禁,同属缓存段。两者与「失败分类表」无语义交集。测试因此新增独立文件,不去改 #242/#274 同时在动的resilience.test.ts。验证
TDD 顺序:先写测试 → 未修复代码上
1 pass / 3 fail→ 修复 →4 pass / 0 fail。测试覆盖:
classifyFailure('ACCESS_DENIED')→'auth'classifyFailure('PERMISSION_DENIED')→'auth'defineCapability走完整链路,断言最终说明句为(ACCESS_DENIED; auth)回归对照:
bun test packages/shared/src/providers/ --isolatebun test packages/shared/src/capabilities/ --isolatebun run typecheck