Skip to content

providers: Massive 的 403 码 ACCESS_DENIED 不在失败分类表内,降级血缘渲染出自相矛盾的「(ACCESS_DENIED; unknown)」 #280

Description

@wxrbyte

现象

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

claimedClaimed by a contributor and currently in progress

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions