fix(usage): reject transient transport failures so retry and keep-last-good work

Usage/quota queries frequently showed spurious "query failed" states that
manual refresh could not clear (#3820). Root cause: all transport-level
failures were folded into Ok(success:false), so react-query's retry never
fired and the failure body poisoned the cache as regular data.

Backend:
- balance/coding_plan/subscription services now return Err for send
  failures and body-read failures (read body via bytes() before
  serde_json::from_slice; reqwest's json() wraps read errors as Decode,
  making error-kind checks on it dead code). Auth/4xx/parse errors stay
  Ok(success:false) and surface immediately.
- Script path maps transient AppError keys (request_failed /
  read_response_failed) to Err; Volcengine adds a Transient call variant.
- Command layer skips snapshot persistence, usage-cache-updated emit and
  tray refresh on Err so the cache bridge cannot overwrite retained data.
- Expired-credential retry propagates transient errors instead of
  rewriting them as "OAuth token has expired".

Frontend:
- resolveDisplayUsage generalized to subscription quotas; 5th param is now
  an options object with a `rejected` flag: stale success data retained by
  react-query across rejections is re-anchored to dataUpdatedAt and expires
  through the same 10-minute keep-last-good window instead of being shown
  indefinitely (a total outage used to mask longer than a single 5xx).
- useSubscriptionQuota / useCodexOauthQuota gain keep-last-good with
  per-scope snapshot reset; rejected queries with no displayable value
  synthesize a failure placeholder carrying the real error message so
  footers keep rendering a retry entry point.
- HTTP 429 is classified transient (retry-later) alongside 5xx.
- UsageScriptModal surfaces string rejections via extractErrorMessage.

Tests: 6 backend behavior tests drive real HTTP against a local listener
to pin the Err/Ok channel semantics; frontend suite extends
keepLastGoodUsage coverage (405 passing).
This commit is contained in:
Jason
2026-07-07 20:05:29 +08:00
parent 468c93d409
commit 2df2212ceb
12 changed files with 724 additions and 268 deletions
+116 -2
View File
@@ -50,7 +50,7 @@ describe("isTransientUsageError", () => {
);
});
it("HTTP 5xx → 瞬时(true);4xx → 非瞬时(false", () => {
it("HTTP 5xx / 429(限流)→ 瞬时(true);其余 4xx → 非瞬时(false", () => {
expect(isTransientUsageError(fail("API error (HTTP 500): oops"))).toBe(
true,
);
@@ -60,12 +60,19 @@ describe("isTransientUsageError", () => {
expect(
isTransientUsageError(fail("API error (HTTP 502): bad gateway")),
).toBe(true);
// 429 是限流:稍后重试即可恢复,归瞬时——且不应清空 keep-last-good 快照
expect(
isTransientUsageError(fail("API error (HTTP 429): rate limited")),
).toBe(false);
).toBe(true);
expect(
isTransientUsageError(fail("HTTP 429 Too Many Requests : x")),
).toBe(true);
expect(
isTransientUsageError(fail("Authentication failed (HTTP 403)")),
).toBe(false);
expect(
isTransientUsageError(fail("Authentication failed (HTTP 401)")),
).toBe(false);
});
it("成功 / 无错误信息 → false", () => {
@@ -173,4 +180,111 @@ describe("resolveDisplayUsage (keep-last-good)", () => {
const r = resolveDisplayUsage(success, 0, null, now);
expect(r.lastGood).toEqual({ data: success, at: now });
});
it("429 限流:窗口内继续展示上次成功,且不清空 lastGood", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const now = T0 + 1000;
const r = resolveDisplayUsage(
fail("API error (HTTP 429): rate limited"),
now,
prev,
now,
);
expect(r.data).toBe(prev.data); // 掩盖:继续展示上次成功
expect(r.lastGood).toBe(prev); // 快照保留,抖动过去后可继续兜底
});
});
// 订阅系 hooksuseSubscriptionQuota / useCodexOauthQuota)复用同一决策函数,
// 这里用 SubscriptionQuota 的形状锁定泛型行为。
describe("resolveDisplayUsageSubscriptionQuota 形状)", () => {
interface QuotaLike {
success: boolean;
error?: string | null;
credentialStatus: string;
queriedAt: number | null;
}
const quotaOk = (queriedAt: number): QuotaLike => ({
success: true,
error: null,
credentialStatus: "valid",
queriedAt,
});
const quotaFail = (error: string): QuotaLike => ({
success: false,
error,
credentialStatus: "valid",
queriedAt: null,
});
it("瞬时 5xx:窗口内继续展示上次成功的 quota(含其 queriedAt", () => {
const prev = { data: quotaOk(T0), at: T0 };
const now = T0 + 1000;
const r = resolveDisplayUsage(
quotaFail("API error (HTTP 502): bad gateway"),
now,
prev,
now,
);
expect(r.data).toBe(prev.data);
expect(r.data?.queriedAt).toBe(T0); // 展示的相对时间指向旧成功
});
it("确定性失败(token 过期):立即透出并清空 lastGood", () => {
const prev = { data: quotaOk(T0), at: T0 };
const now = T0 + 1000;
const failure = quotaFail("OAuth token has expired");
const r = resolveDisplayUsage(failure, now, prev, now);
expect(r.data).toBe(failure);
expect(r.lastGood).toBeNull();
});
});
// invoke reject(后端 Err:网络/超时/读体中断)时 react-query 保留上次成功 data
// `rejected` 标志让这份"陈旧成功"走同一 keep-last-good 窗口(锚定上次成功时刻
// dataUpdatedAt),而不是无限期被当作新鲜成功展示——否则「彻底断网」反而比
// 「单次 5xx」掩盖更久。
describe("resolveDisplayUsagerejectedreject 保留的旧成功按窗口过期)", () => {
it("窗口内:继续展示旧成功;prevLastGood=null(组件重挂丢 ref)也从 dataUpdatedAt 补种", () => {
const stale = ok(42); // react-query 保留的旧成功,dataUpdatedAt = 其成功时刻
const now = T0 + KEEP_LAST_GOOD_MS - 1;
const r = resolveDisplayUsage(stale, T0, null, now, { rejected: true });
expect(r.data).toBe(stale);
expect(r.lastQueriedAt).toBe(T0); // 相对时间指向上次真实成功
expect(r.lastGood).toEqual({ data: stale, at: T0 });
});
it("超窗(>= keepMs):不再展示旧成功(data 置空由调用方合成失败占位),快照保留不清空", () => {
const stale = ok(42);
const now = T0 + KEEP_LAST_GOOD_MS;
const r = resolveDisplayUsage(stale, T0, null, now, { rejected: true });
expect(r.data).toBeUndefined();
expect(r.lastQueriedAt).toBe(T0);
expect(r.lastGood).toEqual({ data: stale, at: T0 }); // reject 属瞬时,不清快照
});
it("reject 且从无成功(raw=undefined,首次查询即失败):data 为 undefinedlastGood 不变", () => {
const r = resolveDisplayUsage(undefined, 0, null, T0, { rejected: true });
expect(r.data).toBeUndefined();
expect(r.lastQueriedAt).toBeNull();
expect(r.lastGood).toBeNull();
});
it("reject 但保留的是确定性失败结果:照旧立即透出并清空 lastGood(rejected 只作用于成功值)", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const failure = fail("Authentication failed (HTTP 401)");
const r = resolveDisplayUsage(failure, T0 + 1000, prev, T0 + 1000, {
rejected: true,
});
expect(r.data).toBe(failure);
expect(r.lastGood).toBeNull();
});
it("rejected 未传(默认 false):成功照常作为新鲜结果,既有语义不变", () => {
const success = ok(7);
const now = T0 + KEEP_LAST_GOOD_MS * 3; // 距 T0 很久也无妨:非 reject 的成功就是新鲜的
const r = resolveDisplayUsage(success, now, null, now);
expect(r.data).toBe(success);
expect(r.lastGood).toEqual({ data: success, at: now });
});
});