mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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); // 快照保留,抖动过去后可继续兜底
|
||||
});
|
||||
});
|
||||
|
||||
// 订阅系 hooks(useSubscriptionQuota / useCodexOauthQuota)复用同一决策函数,
|
||||
// 这里用 SubscriptionQuota 的形状锁定泛型行为。
|
||||
describe("resolveDisplayUsage(SubscriptionQuota 形状)", () => {
|
||||
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("resolveDisplayUsage(rejected:reject 保留的旧成功按窗口过期)", () => {
|
||||
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 为 undefined,lastGood 不变", () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user