diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs index dce56fed8..edeba3888 100644 --- a/src-tauri/src/services/balance.rs +++ b/src-tauri/src/services/balance.rs @@ -72,7 +72,7 @@ async fn query_deepseek(api_key: &str) -> UsageResult { .get("https://api.deepseek.com/user/balance") .header("Authorization", format!("Bearer {api_key}")) .header("Accept", "application/json") - .timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) .send() .await; @@ -144,7 +144,7 @@ async fn query_stepfun(api_key: &str) -> UsageResult { .get("https://api.stepfun.com/v1/accounts") .header("Authorization", format!("Bearer {api_key}")) .header("Accept", "application/json") - .timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) .send() .await; @@ -203,7 +203,7 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { .get(&url) .header("Authorization", format!("Bearer {api_key}")) .header("Accept", "application/json") - .timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) .send() .await; @@ -267,7 +267,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult { .get("https://openrouter.ai/api/v1/credits") .header("Authorization", format!("Bearer {api_key}")) .header("Accept", "application/json") - .timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) .send() .await; @@ -327,7 +327,7 @@ async fn query_novita(api_key: &str) -> UsageResult { .get("https://api.novita.ai/v3/user/balance") .header("Authorization", format!("Bearer {api_key}")) .header("Accept", "application/json") - .timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) .send() .await; diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index bb547f207..25c2f009b 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -95,7 +95,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { .get("https://api.kimi.com/coding/v1/usages") .header("Authorization", format!("Bearer {api_key}")) .header("Accept", "application/json") - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .send() .await; @@ -308,7 +308,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { .header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀 .header("Content-Type", "application/json") .header("Accept-Language", "en-US,en") - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .send() .await; @@ -391,7 +391,7 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { .get(&url) .header("Authorization", format!("Bearer {api_key}")) .header("Content-Type", "application/json") - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .send() .await; @@ -463,7 +463,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { .get(base_url) .header("Authorization", format!("Bearer {api_key}")) .header("Accept", "application/json") - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .send() .await; @@ -666,7 +666,8 @@ pub async fn get_coding_plan_quota( success: false, tiers: vec![], extra_usage: None, - error: None, + // 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败 + error: Some("API key is empty".to_string()), queried_at: None, }); } @@ -681,9 +682,10 @@ pub async fn get_coding_plan_quota( success: false, tiers: vec![], extra_usage: None, - error: None, + // 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败 + error: Some("Unknown coding plan provider".to_string()), queried_at: None, - }) + }); } }; diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index c7a6fa66c..092100349 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -328,7 +328,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { .header("Authorization", format!("Bearer {access_token}")) .header("anthropic-beta", "oauth-2025-04-20") .header("Accept", "application/json") - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .send() .await; @@ -668,7 +668,7 @@ pub(crate) async fn query_codex_quota( req = req.header("ChatGPT-Account-Id", id); } - let resp = match req.timeout(std::time::Duration::from_secs(10)).send().await { + let resp = match req.timeout(std::time::Duration::from_secs(15)).send().await { Ok(r) => r, Err(e) => { return SubscriptionQuota::error( @@ -964,7 +964,7 @@ async fn refresh_gemini_token(refresh_token: &str) -> Option { ("refresh_token", refresh_token), ("grant_type", "refresh_token"), ]) - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .send() .await .ok()?; @@ -1048,7 +1048,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { "pluginType": "GEMINI" } })) - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .send() .await; @@ -1109,7 +1109,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { .header("Authorization", format!("Bearer {access_token}")) .header("Content-Type", "application/json") .json("a_body) - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .send() .await; diff --git a/src/lib/query/queries.ts b/src/lib/query/queries.ts index ef0711c36..28cba7556 100644 --- a/src/lib/query/queries.ts +++ b/src/lib/query/queries.ts @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { useQuery, type UseQueryResult, @@ -99,6 +100,112 @@ export interface UseUsageQueryOptions { autoQueryInterval?: number; // 自动查询间隔(分钟),0 表示禁用 } +/** 最近一次成功的用量结果快照(keep-last-good 用)。 */ +export interface LastGoodUsage { + data: UsageResult; + at: number; // 该成功结果的获取时刻(ms) +} + +/** 在最近一次成功后多久内,失败仍继续展示该成功值。 */ +export const KEEP_LAST_GOOD_MS = 10 * 60 * 1000; // 10 分钟 + +/** + * 判断一次用量查询失败是否属于"瞬时/网络类"(可被 keep-last-good 短暂掩盖)。 + * + * 仅瞬时失败才允许继续展示上一次成功;**确定性失败**(鉴权失败、空 API Key、 + * 未知供应商、4xx、脚本/解析错误等)必须立即透出——用户改/删凭据后要马上看到, + * 否则会一直显示过期额度直到窗口结束。 + * + * 采用**白名单**:只认后端稳定的网络类错误前缀 + HTTP 5xx,失败安全——任何未识别 + * 的错误一律按"非瞬时"立即透出,绝不误掩盖确定性失败。需与后端错误文案保持同步: + * - 原生 balance/coding_plan/subscription:reqwest send 失败/超时 → `"Network error: …"`; + * 上游非 2xx → `"API error (HTTP …)"` + * - JS 脚本 usage_script:`request_failed` → "请求失败/Request failed"、 + * `read_response_failed` → "读取响应失败/Failed to read response"(仅 zh/en 两种本地化); + * 上游非 2xx → `"HTTP …"` + * + * HTTP 状态:**5xx**(服务端错误,通常瞬时)归为 transient;**4xx**(鉴权/客户端 + * 错误,如 401/403/404/429)保持确定性,立即透出。 + */ +export function isTransientUsageError(result: UsageResult): boolean { + if (result.success) return false; + const e = result.error?.toLowerCase() ?? ""; + if (!e) return false; + + // 网络类(send 失败/超时/读取响应失败) + if ( + e.includes("network error") || // 原生路径 + e.includes("request failed") || // JS 脚本 (en) + e.includes("请求失败") || // JS 脚本 (zh) + e.includes("failed to read response") || // JS 脚本 (en) + e.includes("读取响应失败") // JS 脚本 (zh) + ) { + return true; + } + + // HTTP 状态码:5xx 视为瞬时,4xx 视为确定性。错误文案里第一处 "HTTP " 即为 + // 上游状态码(原生 "API error (HTTP 500…)"、JS 脚本 "HTTP 500 …")。 + const httpMatch = e.match(/http\s+(\d{3})/); + if (httpMatch) { + const status = Number(httpMatch[1]); + return status >= 500 && status <= 599; + } + + return false; +} + +/** + * Keep-last-good 的纯决策函数(无 ref、无时钟,`now` 注入以便测试)。 + * + * 用量端点面向跨境/第三方,单次网络抖动会让后端返回 `Ok(success:false)` 当作"新数据" + * 覆盖掉上一次成功,使卡片瞬间翻红——这是"一会成功一会失败"观感的主因。 + * 策略:当失败是**瞬时/网络类**(见 [`isTransientUsageError`])且最近一次成功在 + * `keepMs` 内时,不抹掉它,继续展示该成功值,并把 `lastQueriedAt` 指向该成功的时刻 + * (相对时间自然走到"10 分钟前"后过期翻红);超出窗口、或从无成功记录时照常展示失败。 + * + * **确定性失败**(鉴权/空 key/未知供应商/4xx 等)不仅立即透出,还会**清空 `lastGood`**: + * 旧成功快照已不可信,否则随后一次网络抖动会把"配置/鉴权已失效"的旧额度重新复活。 + * + * 注:会 reject 的传输层失败(Copilot/DB)react-query 本就保留上次 `data`,这里 + * 主要修的是 `Ok(success:false)` 这条覆盖路径。 + */ +export function resolveDisplayUsage( + raw: UsageResult | undefined, + dataUpdatedAt: number, + prevLastGood: LastGoodUsage | null, + now: number, + keepMs: number = KEEP_LAST_GOOD_MS, +): { + data: UsageResult | undefined; + lastQueriedAt: number | null; + lastGood: LastGoodUsage | null; +} { + let lastGood = prevLastGood; + if (raw?.success) { + // 成功:刷新快照 + lastGood = { data: raw, at: dataUpdatedAt || now }; + } else if (raw && !isTransientUsageError(raw)) { + // 确定性失败(鉴权/空 key/未知供应商/4xx 等):旧成功快照已不可信,丢弃它, + // 避免后续一次网络抖动把"配置/鉴权已失效"的旧额度重新复活。 + lastGood = null; + } + + let data = raw; + let lastQueriedAt = dataUpdatedAt || null; + if ( + raw && + !raw.success && + isTransientUsageError(raw) && // 仅瞬时/网络类失败才掩盖;确定性失败立即透出 + lastGood && + now - lastGood.at < keepMs + ) { + data = lastGood.data; + lastQueriedAt = lastGood.at; + } + + return { data, lastQueriedAt, lastGood }; +} + export const useUsageQuery = ( providerId: string, appId: AppId, @@ -123,14 +230,31 @@ export const useUsageQuery = ( : false, refetchIntervalInBackground: true, // 后台也继续定时查询 refetchOnWindowFocus: false, - retry: false, + // 用量查询面向跨境/第三方端点,单次网络抖动或瞬时 5xx 不应直接判失败。 + // 重试一次以吸收瞬时故障(与 useSubscriptionQuota 的 retry:1 保持一致)。 + // 注意:原生 balance/coding_plan 路径把网络错误折叠成 Ok(success:false), + // 这类不会触发 react-query 重试;本项主要覆盖会 reject 的传输层失败(Copilot/DB 等)。 + retry: 1, + retryDelay: 1500, staleTime, // 使用动态计算的缓存时间 gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后) }); + // Keep-last-good:失败时在 10 分钟窗口内继续展示上一次成功值(见 resolveDisplayUsage)。 + // 每个 hook 实例各持一份 ref(按卡片维度);ref 写入是幂等的(同份成功重复写无副作用)。 + const lastGoodRef = useRef(null); + const { data, lastQueriedAt, lastGood } = resolveDisplayUsage( + query.data, + query.dataUpdatedAt, + lastGoodRef.current, + Date.now(), + ); + lastGoodRef.current = lastGood; + return { ...query, - lastQueriedAt: query.dataUpdatedAt || null, + data, + lastQueriedAt, }; }; diff --git a/tests/lib/keepLastGoodUsage.test.ts b/tests/lib/keepLastGoodUsage.test.ts new file mode 100644 index 000000000..e696f9f26 --- /dev/null +++ b/tests/lib/keepLastGoodUsage.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from "vitest"; +import { + resolveDisplayUsage, + isTransientUsageError, + KEEP_LAST_GOOD_MS, + type LastGoodUsage, +} from "@/lib/query/queries"; +import type { UsageResult } from "@/types"; + +// keep-last-good 的纯决策逻辑:仅"瞬时/网络类"失败才在 KEEP_LAST_GOOD_MS 窗口内继续 +// 展示上一次成功;确定性失败(鉴权/空 key/未知供应商等)必须立即透出。 + +const ok = (remaining: number): UsageResult => ({ + success: true, + data: [{ remaining, unit: "USD" }], +}); +// 默认用网络类错误(瞬时),需要确定性失败时显式传入。 +const fail = (error = "Network error: connection reset"): UsageResult => ({ + success: false, + error, +}); + +const T0 = 1_000_000_000_000; // 任意基准时刻(ms) + +describe("isTransientUsageError", () => { + it("网络类失败 → 瞬时(true)", () => { + expect(isTransientUsageError(fail("Network error: timed out"))).toBe(true); + expect(isTransientUsageError(fail("Request failed: timed out"))).toBe(true); + expect(isTransientUsageError(fail("请求失败: 连接超时"))).toBe(true); + expect(isTransientUsageError(fail("Failed to read response: eof"))).toBe( + true, + ); + expect(isTransientUsageError(fail("读取响应失败: eof"))).toBe(true); + }); + + it("确定性失败 → 非瞬时(false),必须立即透出", () => { + expect( + isTransientUsageError(fail("Authentication failed (HTTP 401)")), + ).toBe(false); + expect(isTransientUsageError(fail("API key is empty"))).toBe(false); + expect(isTransientUsageError(fail("Unknown balance provider"))).toBe(false); + expect(isTransientUsageError(fail("Unknown coding plan provider"))).toBe( + false, + ); + expect(isTransientUsageError(fail("API error (HTTP 400): bad"))).toBe( + false, + ); + expect(isTransientUsageError(fail("Failed to parse response: x"))).toBe( + false, + ); + }); + + it("HTTP 5xx → 瞬时(true);4xx → 非瞬时(false)", () => { + expect(isTransientUsageError(fail("API error (HTTP 500): oops"))).toBe( + true, + ); + expect( + isTransientUsageError(fail("HTTP 503 Service Unavailable : x")), + ).toBe(true); + expect( + isTransientUsageError(fail("API error (HTTP 502): bad gateway")), + ).toBe(true); + expect( + isTransientUsageError(fail("API error (HTTP 429): rate limited")), + ).toBe(false); + expect( + isTransientUsageError(fail("Authentication failed (HTTP 403)")), + ).toBe(false); + }); + + it("成功 / 无错误信息 → false", () => { + expect(isTransientUsageError(ok(1))).toBe(false); + expect(isTransientUsageError({ success: false })).toBe(false); + }); +}); + +describe("resolveDisplayUsage (keep-last-good)", () => { + it("成功结果:原样展示并记录为 lastGood,lastQueriedAt=获取时刻", () => { + const success = ok(42); + const r = resolveDisplayUsage(success, T0, null, T0); + expect(r.data).toBe(success); + expect(r.lastQueriedAt).toBe(T0); + expect(r.lastGood).toEqual({ data: success, at: T0 }); + }); + + it("瞬时失败 + 窗口内有上次成功:继续展示成功值,lastQueriedAt 指向成功时刻", () => { + const prev: LastGoodUsage = { data: ok(42), at: T0 }; + const now = T0 + KEEP_LAST_GOOD_MS - 1; // 刚好仍在窗口内 + const r = resolveDisplayUsage(fail(), now, prev, now); + expect(r.data).toBe(prev.data); // 展示的是上次成功 + expect(r.lastQueriedAt).toBe(T0); // 时间戳反映成功的年龄 + expect(r.lastGood).toBe(prev); // 失败不更新 lastGood + }); + + it("瞬时失败 + 上次成功已过期(>= 窗口):展示失败本身", () => { + const prev: LastGoodUsage = { data: ok(42), at: T0 }; + const now = T0 + KEEP_LAST_GOOD_MS; // 边界:恰好到 10 分钟即过期 + const failure = fail(); + const r = resolveDisplayUsage(failure, now, prev, now); + expect(r.data).toBe(failure); + expect(r.lastQueriedAt).toBe(now); + expect(r.lastGood).toBe(prev); + }); + + it("确定性失败(鉴权/空 key/未知供应商):即使窗口内有上次成功也立即透出,并清空 lastGood", () => { + const prev: LastGoodUsage = { data: ok(42), at: T0 }; + const now = T0 + 1000; // 远在窗口内 + for (const failure of [ + fail("Authentication failed (HTTP 401)"), + fail("API key is empty"), + fail("Unknown coding plan provider"), + ]) { + const r = resolveDisplayUsage(failure, now, prev, now); + expect(r.data).toBe(failure); // 不掩盖 → 透出确定性失败 + expect(r.lastQueriedAt).toBe(now); + expect(r.lastGood).toBeNull(); // 旧快照已不可信 → 清空,防止后续被复活 + } + }); + + it("确定性失败清空 lastGood:随后的网络抖动不会复活旧成功", () => { + // 成功 → 记录 lastGood + const afterSuccess = resolveDisplayUsage(ok(42), T0, null, T0); + expect(afterSuccess.lastGood).not.toBeNull(); + // 401 鉴权失败 → 透出失败并清空 lastGood + const afterAuthFail = resolveDisplayUsage( + fail("Authentication failed (HTTP 401)"), + T0 + 1000, + afterSuccess.lastGood, + T0 + 1000, + ); + expect(afterAuthFail.lastGood).toBeNull(); + // 随后一次网络抖动(瞬时)→ lastGood 已空 → 不复活旧成功,照常透出失败 + const netFail = fail(); + const afterBlip = resolveDisplayUsage( + netFail, + T0 + 2000, + afterAuthFail.lastGood, + T0 + 2000, + ); + expect(afterBlip.data).toBe(netFail); + expect(afterBlip.lastGood).toBeNull(); + }); + + it("瞬时失败 + 从无成功记录:展示失败本身", () => { + const failure = fail(); + const now = T0 + 5000; + const r = resolveDisplayUsage(failure, now, null, now); + expect(r.data).toBe(failure); + expect(r.lastQueriedAt).toBe(now); + expect(r.lastGood).toBeNull(); + }); + + it("新的成功覆盖旧的 lastGood", () => { + const prev: LastGoodUsage = { data: ok(42), at: T0 }; + const fresh = ok(7); + const now = T0 + 60_000; + const r = resolveDisplayUsage(fresh, now, prev, now); + expect(r.data).toBe(fresh); + expect(r.lastGood).toEqual({ data: fresh, at: now }); + }); + + it("加载中(raw=undefined):data 为 undefined,lastGood 不变", () => { + const prev: LastGoodUsage = { data: ok(42), at: T0 }; + const r = resolveDisplayUsage(undefined, 0, prev, T0 + 1000); + expect(r.data).toBeUndefined(); + expect(r.lastQueriedAt).toBeNull(); + expect(r.lastGood).toBe(prev); + }); + + it("dataUpdatedAt 为 0 的成功:用注入的 now 作为获取时刻", () => { + const success = ok(1); + const now = T0 + 123; + const r = resolveDisplayUsage(success, 0, null, now); + expect(r.lastGood).toEqual({ data: success, at: now }); + }); +});