diff --git a/src-tauri/src/commands/codex_oauth.rs b/src-tauri/src/commands/codex_oauth.rs index eb1ceb663..6aa33b966 100644 --- a/src-tauri/src/commands/codex_oauth.rs +++ b/src-tauri/src/commands/codex_oauth.rs @@ -49,13 +49,14 @@ pub async fn get_codex_oauth_quota( } }; - Ok(query_codex_quota( + // 瞬时传输失败以 Err 传播(前端 reject → retry + 保留上次成功值)。 + query_codex_quota( &token, Some(&id), "codex_oauth", "Codex OAuth access token expired or rejected. Please re-login via cc-switch.", ) - .await) + .await } /// 获取 Codex OAuth (ChatGPT Plus/Pro) 可用模型列表 diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 059fd814c..7a90e2e87 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -381,32 +381,30 @@ pub async fn queryProviderUsage( ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; // inner 可能以两种形式失败: - // 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等) - // 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败 - // 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的 - // success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。 - // 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。 + // 1) 返回 Ok(UsageResult { success: false, .. }) —— 确定性失败(401、脚本 + // 报错、未知供应商等)。写进 UsageCache 并刷新托盘,让 + // format_script_summary 的 success 守卫生效、suffix 自然消失。 + // 2) 返回 Err(String) —— 瞬时传输失败(网络/超时)及 DB/Copilot fetch 等。 + // 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject + // 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写 + // 回 query 缓存,抹掉 reject 本该保留的旧值。 let inner = query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await; - let snapshot = match &inner { - Ok(r) => r.clone(), - Err(err_msg) => crate::provider::UsageResult { - success: false, - data: None, - error: Some(err_msg.clone()), - }, - }; - let payload = serde_json::json!({ - "kind": "script", - "appType": app_type.as_str(), - "providerId": &providerId, - "data": &snapshot, - }); - if let Err(e) = app_handle.emit("usage-cache-updated", payload) { - log::error!("emit usage-cache-updated (script) 失败: {e}"); + if let Ok(snapshot) = &inner { + let payload = serde_json::json!({ + "kind": "script", + "appType": app_type.as_str(), + "providerId": &providerId, + "data": snapshot, + }); + if let Err(e) = app_handle.emit("usage-cache-updated", payload) { + log::error!("emit usage-cache-updated (script) 失败: {e}"); + } + state + .usage_cache + .put_script(app_type, providerId, snapshot.clone()); + crate::tray::schedule_tray_refresh(&app_handle); } - state.usage_cache.put_script(app_type, providerId, snapshot); - crate::tray::schedule_tray_refresh(&app_handle); inner } diff --git a/src-tauri/src/commands/subscription.rs b/src-tauri/src/commands/subscription.rs index 053045b71..d258a8bd4 100644 --- a/src-tauri/src/commands/subscription.rs +++ b/src-tauri/src/commands/subscription.rs @@ -2,17 +2,19 @@ use std::str::FromStr; use tauri::{Emitter, State}; use crate::app_config::AppType; -use crate::services::subscription::{CredentialStatus, SubscriptionQuota}; +use crate::services::subscription::SubscriptionQuota; use crate::store::AppState; /// 查询官方订阅额度 /// /// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。 -/// 结果(无论业务失败还是 transport 层 Err)都会写入 `UsageCache`、通知托盘 -/// 刷新,并 emit `usage-cache-updated`,让前端 React Query 与托盘共享同一份 -/// 最新数据。失败快照写入后 `format_subscription_summary` 会通过 `success=false` -/// 守卫返回 `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。 -/// Err 原样向前端返回,React Query 的 onError 不会被吞掉。 +/// `Ok`(成功或确定性失败)写入 `UsageCache`、通知托盘刷新并 emit +/// `usage-cache-updated`,让前端 React Query 与托盘共享同一份最新数据;失败 +/// 快照写入后 `format_subscription_summary` 会通过 `success=false` 守卫返回 +/// `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。 +/// `Err`(瞬时传输失败)不写快照、不 emit:保留上一份托盘快照,与前端 +/// react-query reject 保留上次 data 的语义一致(emit 失败快照会经 +/// `useUsageCacheBridge` 盲写回 query 缓存,抹掉本该保留的旧值)。 #[tauri::command] pub async fn get_subscription_quota( app: tauri::AppHandle, @@ -20,22 +22,21 @@ pub async fn get_subscription_quota( tool: String, ) -> Result { let inner = crate::services::subscription::get_subscription_quota(&tool).await; - let snapshot = match &inner { - Ok(q) => q.clone(), - // transport 层 Err —— 凭据状态不明,用 Valid 表达"凭据没问题,是通信/parse 出错"。 - Err(err_msg) => SubscriptionQuota::error(&tool, CredentialStatus::Valid, err_msg.clone()), - }; - if let Ok(app_type) = AppType::from_str(&tool) { - let payload = serde_json::json!({ - "kind": "subscription", - "appType": app_type.as_str(), - "data": &snapshot, - }); - if let Err(e) = app.emit("usage-cache-updated", payload) { - log::error!("emit usage-cache-updated (subscription) 失败: {e}"); + if let Ok(snapshot) = &inner { + if let Ok(app_type) = AppType::from_str(&tool) { + let payload = serde_json::json!({ + "kind": "subscription", + "appType": app_type.as_str(), + "data": snapshot, + }); + if let Err(e) = app.emit("usage-cache-updated", payload) { + log::error!("emit usage-cache-updated (subscription) 失败: {e}"); + } + state + .usage_cache + .put_subscription(app_type, snapshot.clone()); + crate::tray::schedule_tray_refresh(&app); } - state.usage_cache.put_subscription(app_type, snapshot); - crate::tray::schedule_tray_refresh(&app); } inner } diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs index edeba3888..8639b1d98 100644 --- a/src-tauri/src/services/balance.rs +++ b/src-tauri/src/services/balance.rs @@ -2,6 +2,12 @@ //! //! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。 //! 返回 UsageResult 格式,与现有用量系统无缝对接。 +//! +//! 错误通道语义(与 coding_plan / subscription 两个服务保持一致): +//! - `Err(String)` = 瞬时传输失败(网络不可达/超时/读体中断)。前端 invoke reject, +//! react-query 触发 retry 并保留上一次成功的 data(天然 keep-last-good)。 +//! - `Ok(success:false)` = 确定性失败(空 key/未知供应商/鉴权/非 2xx/响应体非法 JSON), +//! 立即透出错误文案。判定按 reqwest 错误种类在折叠点完成,不依赖错误文案匹配。 use crate::provider::{UsageData, UsageResult}; use std::time::Duration; @@ -65,7 +71,7 @@ fn make_auth_error(status: reqwest::StatusCode) -> UsageResult { // GET https://api.deepseek.com/user/balance // Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available } -async fn query_deepseek(api_key: &str) -> UsageResult { +async fn query_deepseek(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -78,21 +84,27 @@ async fn query_deepseek(api_key: &str) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let is_available = body @@ -126,18 +138,18 @@ async fn query_deepseek(api_key: &str) -> UsageResult { } } - UsageResult { + Ok(UsageResult { success: true, data: if data.is_empty() { None } else { Some(data) }, error: None, - } + }) } // ── StepFun ───────────────────────────────────────────────── // GET https://api.stepfun.com/v1/accounts // Response: { object, type, balance, total_cash_balance, total_voucher_balance } -async fn query_stepfun(api_key: &str) -> UsageResult { +async fn query_stepfun(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -150,26 +162,32 @@ async fn query_stepfun(api_key: &str) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let balance = parse_f64_field(&body, "balance").unwrap_or(0.0); - UsageResult { + Ok(UsageResult { success: true, data: Some(vec![UsageData { plan_name: Some("StepFun".to_string()), @@ -182,14 +200,14 @@ async fn query_stepfun(api_key: &str) -> UsageResult { extra: None, }]), error: None, - } + }) } // ── SiliconFlow ───────────────────────────────────────────── // GET https://api.siliconflow.cn/v1/user/info (or .com for EN) // Response: { code, data: { balance, chargeBalance, totalBalance, status } } -async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { +async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result { let client = crate::proxy::http_client::get(); let domain = if is_cn { @@ -209,26 +227,32 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let data = match body.get("data") { Some(d) => d, - None => return make_error("Missing 'data' field in response".to_string()), + None => return Ok(make_error("Missing 'data' field in response".to_string())), }; let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0); @@ -240,7 +264,7 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { "SiliconFlow (EN)" }; - UsageResult { + Ok(UsageResult { success: true, data: Some(vec![UsageData { plan_name: Some(plan_name.to_string()), @@ -253,14 +277,14 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { extra: None, }]), error: None, - } + }) } // ── OpenRouter ────────────────────────────────────────────── // GET https://openrouter.ai/api/v1/credits // Response: { data: { total_credits, total_usage } } -async fn query_openrouter(api_key: &str) -> UsageResult { +async fn query_openrouter(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -273,21 +297,27 @@ async fn query_openrouter(api_key: &str) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let data = body.get("data").unwrap_or(&body); @@ -295,7 +325,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult { let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0); let remaining = total_credits - total_usage; - UsageResult { + Ok(UsageResult { success: true, data: Some(vec![UsageData { plan_name: Some("OpenRouter".to_string()), @@ -312,7 +342,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult { extra: None, }]), error: None, - } + }) } // ── Novita AI ─────────────────────────────────────────────── @@ -320,7 +350,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult { // Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices } // 金额单位:0.0001 USD -async fn query_novita(api_key: &str) -> UsageResult { +async fn query_novita(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -333,27 +363,33 @@ async fn query_novita(api_key: &str) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; // Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0; - UsageResult { + Ok(UsageResult { success: true, data: Some(vec![UsageData { plan_name: Some("Novita AI".to_string()), @@ -370,7 +406,7 @@ async fn query_novita(api_key: &str) -> UsageResult { extra: None, }]), error: None, - } + }) } // ── 工具函数 ──────────────────────────────────────────────── @@ -385,6 +421,8 @@ fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option { // ── 公开入口 ──────────────────────────────────────────────── +/// 查询余额。瞬时传输失败返回 `Err`(前端 reject → retry + 保留上次成功值), +/// 确定性失败返回 `Ok(success:false)`(见模块级文档)。 pub async fn get_balance(base_url: &str, api_key: &str) -> Result { if api_key.trim().is_empty() { return Ok(UsageResult { @@ -405,14 +443,12 @@ pub async fn get_balance(base_url: &str, api_key: &str) -> Result query_deepseek(api_key).await, BalanceProvider::StepFun => query_stepfun(api_key).await, BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await, BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await, BalanceProvider::OpenRouter => query_openrouter(api_key).await, BalanceProvider::NovitaAI => query_novita(api_key).await, - }; - - Ok(result) + } } diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 19098db67..172e2df69 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -99,7 +99,7 @@ fn make_error(msg: String) -> SubscriptionQuota { // ── Kimi For Coding ───────────────────────────────────────── -async fn query_kimi(api_key: &str) -> SubscriptionQuota { +async fn query_kimi(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -112,12 +112,12 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota { + return Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Expired, credential_message: Some("Invalid API key".to_string()), @@ -126,17 +126,23 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), - }; + }); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let mut tiers = Vec::new(); @@ -187,7 +193,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { }); } - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -196,7 +202,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── 智谱 GLM ──────────────────────────────────────────────── @@ -307,7 +313,7 @@ fn zhipu_quota_base(base_url: &str) -> &'static str { } } -async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { +async fn query_zhipu(base_url: &str, api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let url = format!( "{}/api/monitor/usage/quota/limit", @@ -325,12 +331,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota { + return Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Expired, credential_message: Some("Invalid API key".to_string()), @@ -339,17 +345,23 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), - }; + }); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; // 检查业务级别错误 @@ -358,12 +370,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { .get("msg") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return make_error(format!("API error: {msg}")); + return Ok(make_error(format!("API error: {msg}"))); } let data = match body.get("data") { Some(d) => d, - None => return make_error("Missing 'data' field in response".to_string()), + None => return Ok(make_error("Missing 'data' field in response".to_string())), }; let tiers = parse_zhipu_token_tiers(data); @@ -374,7 +386,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Valid, credential_message: level, @@ -383,12 +395,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── MiniMax ───────────────────────────────────────────────── -async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { +async fn query_minimax(api_key: &str, is_cn: bool) -> Result { let client = crate::proxy::http_client::get(); let api_domain = if is_cn { @@ -408,12 +420,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota { + return Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Expired, credential_message: Some("Invalid API key".to_string()), @@ -422,17 +434,23 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), - }; + }); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; // 检查业务级别错误 @@ -446,14 +464,14 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { .get("status_msg") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return make_error(format!("API error (code {status_code}): {msg}")); + return Ok(make_error(format!("API error (code {status_code}): {msg}"))); } } // 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比 let tiers = parse_minimax_tiers(&body); - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -462,12 +480,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── ZenMux ────────────────────────────────────────────────── -async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { +async fn query_zenmux(base_url: &str, api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -480,12 +498,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota { + return Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Expired, credential_message: Some("Invalid API key".to_string()), @@ -494,17 +512,23 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), - }; + }); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; // 检查业务级别错误 @@ -513,12 +537,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { .get("message") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return make_error(format!("API error: {msg}")); + return Ok(make_error(format!("API error: {msg}"))); } let data = match body.get("data") { Some(d) => d, - None => return make_error("Missing 'data' field in response".to_string()), + None => return Ok(make_error("Missing 'data' field in response".to_string())), }; let mut tiers = Vec::new(); @@ -581,7 +605,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { String::new() }; - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Valid, credential_message: if plan_info.is_empty() { @@ -594,7 +618,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } /// 从 `/coding_plan/remains` 响应中解析 MiniMax 编程套餐的额度 tier。 @@ -690,8 +714,11 @@ enum VolcCall { /// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan /// 共用凭据,命中即停。 Auth(String), - /// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。 + /// 非鉴权 HTTP 错误 / 响应体非法 JSON——记录后可继续尝试另一个 plan。 Soft(String), + /// 瞬时传输失败(网络/超时/读体中断)——同 host 的另一个 plan 大概率同样 + /// 失败,调用方应立即以 `Err` 传播(前端 reject → retry + 保留上次成功值)。 + Transient(String), } /// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如 @@ -889,7 +916,7 @@ async fn volcengine_openapi_call( let resp = match resp { Ok(r) => r, - Err(e) => return VolcCall::Soft(format!("Network error: {e}")), + Err(e) => return VolcCall::Transient(format!("Network error: {e}")), }; let status = resp.status(); @@ -916,7 +943,13 @@ async fn volcengine_openapi_call( return VolcCall::Soft(format!("API error (HTTP {status}): {raw}")); } - let body: serde_json::Value = match resp.json().await { + // 同 Bearer 路径:先 bytes() 再解析——读体失败是瞬时(Transient),解析失败 + // 是确定性(Soft)。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return VolcCall::Transient(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")), }; @@ -1058,7 +1091,7 @@ async fn query_volcengine( base_url: &str, access_key_id: &str, secret_access_key: &str, -) -> SubscriptionQuota { +) -> Result { let region = volcengine_region(base_url); let mut soft_errors: Vec = Vec::new(); // 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅" @@ -1071,7 +1104,8 @@ async fn query_volcengine( // 1) Agent Plan:GetAFPUsage match volcengine_openapi_call(®ion, access_key_id, secret_access_key, "GetAFPUsage").await { - VolcCall::Auth(detail) => return volcengine_auth_error(detail), + VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)), + VolcCall::Transient(detail) => return Err(format!("GetAFPUsage: {detail}")), VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")), VolcCall::Body(body) => { let result = body.get("Result").unwrap_or(&body); @@ -1083,7 +1117,7 @@ async fn query_volcengine( .map(str::trim) .filter(|s| !s.is_empty()) .map(|s| format!("Agent Plan {s}")); - return volcengine_success(tiers, plan); + return Ok(volcengine_success(tiers, plan)); } empty_responses.push(summarize("GetAFPUsage", &body)); } @@ -1098,32 +1132,33 @@ async fn query_volcengine( ) .await { - VolcCall::Auth(detail) => return volcengine_auth_error(detail), + VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)), + VolcCall::Transient(detail) => return Err(format!("GetCodingPlanUsage: {detail}")), VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")), VolcCall::Body(body) => { let result = body.get("Result").unwrap_or(&body); let tiers = parse_coding_plan_tiers(result); if !tiers.is_empty() { - return volcengine_success(tiers, Some("Coding Plan".to_string())); + return Ok(volcengine_success(tiers, Some("Coding Plan".to_string()))); } empty_responses.push(summarize("GetCodingPlanUsage", &body)); } } if !soft_errors.is_empty() { - make_error(soft_errors.join("; ")) + Ok(make_error(soft_errors.join("; "))) } else if !empty_responses.is_empty() { // 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应, // 便于核对真实字段名/包裹层,或确认确实未订阅。 - make_error(format!( + Ok(make_error(format!( "No active subscription found (signature OK). Raw: {}", empty_responses.join(" || ") - )) + ))) } else { - make_error( + Ok(make_error( "No active Agent Plan or Coding Plan subscription found for this credential" .to_string(), - ) + )) } } @@ -1143,6 +1178,9 @@ fn coding_plan_not_found(error: &str) -> SubscriptionQuota { } } +/// 查询编程套餐额度。瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject → +/// retry + 保留上次成功值);确定性失败(凭据缺失/未知域名/鉴权/非 2xx/业务错误) +/// 返回 `Ok(success:false)` 立即透出文案。判定按 reqwest 错误种类在折叠点完成。 pub async fn get_coding_plan_quota( base_url: &str, api_key: &str, @@ -1165,7 +1203,7 @@ pub async fn get_coding_plan_quota( "Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)", )); } - return Ok(query_volcengine(base_url, ak, sk).await); + return query_volcengine(base_url, ak, sk).await; } // 其余供应商:数据面 Bearer api_key。 @@ -1174,7 +1212,7 @@ pub async fn get_coding_plan_quota( return Ok(coding_plan_not_found("API key is empty")); } - let quota = match provider { + match provider { CodingPlanProvider::Kimi => query_kimi(api_key).await, CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => { query_zhipu(base_url, api_key).await @@ -1186,9 +1224,7 @@ pub async fn get_coding_plan_quota( CodingPlanProvider::Volcengine => { unreachable!("volcengine handled via AK/SK branch above") } - }; - - Ok(quota) + } } #[cfg(test)] @@ -1806,4 +1842,140 @@ mod tests { let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} }); assert!(volcengine_response_error(&ok_body).is_none()); } + + // ── 传输层错误通道语义:瞬时 → Err(前端 reject/retry),确定性 → Ok(success:false) ── + // + // 借 ZenMux 分支可指向任意 base_url 的特性,用本地 listener 驱动真实 HTTP + // 路径,锁定 send 失败 / 读体中断 / 4xx / 非法 JSON 各自落在哪条通道。 + // balance / subscription 服务与本文件共用同一折叠模式,这里的用例同时充当 + // 三个服务的语义回归锚。 + + use super::get_coding_plan_quota; + use crate::services::subscription::CredentialStatus; + use std::io::{Read, Write}; + + /// 测试进程内可能有其他用例临时 set_var HTTP_PROXY(http_client 的 + /// loopback 检测测试),NO_PROXY 保证本地回环请求始终直连。 + fn ensure_no_proxy_for_loopback() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::env::set_var("NO_PROXY", "127.0.0.1,localhost"); + std::env::set_var("no_proxy", "127.0.0.1,localhost"); + }); + } + + /// 起一个只服务一次连接的本地 HTTP server。`response=None` 表示读完请求 + /// 直接断开(模拟响应前连接中断)。返回可命中 ZenMux 分支的 base_url。 + fn spawn_once_server(response: Option) -> (String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener"); + let port = listener.local_addr().expect("local addr").port(); + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 2048]; + let _ = stream.read(&mut buf); + if let Some(resp) = response { + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + } + }); + (format!("http://127.0.0.1:{port}/zenmux"), handle) + } + + fn http_response(status_line: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + } + + #[tokio::test] + async fn transient_connection_refused_returns_err() { + ensure_no_proxy_for_loopback(); + // 绑定后立刻释放端口 → 连接被拒(send 失败) + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("local addr").port(); + drop(listener); + + let result = + get_coding_plan_quota(&format!("http://127.0.0.1:{port}/zenmux"), "k", None, None) + .await; + let err = result.expect_err("send 失败必须走 Err 通道(瞬时,前端 reject 后重试)"); + assert!(err.contains("Network error"), "err={err}"); + } + + #[tokio::test] + async fn transient_connection_closed_before_response_returns_err() { + ensure_no_proxy_for_loopback(); + let (base_url, handle) = spawn_once_server(None); + + let result = get_coding_plan_quota(&base_url, "k", None, None).await; + let err = result.expect_err("响应前连接中断必须走 Err 通道(瞬时)"); + assert!(err.contains("Network error"), "err={err}"); + handle.join().expect("server thread"); + } + + #[tokio::test] + async fn transient_truncated_body_returns_err() { + ensure_no_proxy_for_loopback(); + // 声明 content-length: 100 但只写一小段就断开 → 读体中断。 + // 锁定 bytes() 先于解析:这类失败必须走 Err(瞬时),不能因 reqwest 把 + // 读体错误包成 decode 而被误判成确定性的 "Failed to parse response"。 + let (base_url, handle) = spawn_once_server(Some( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\npartial" + .to_string(), + )); + + let result = get_coding_plan_quota(&base_url, "k", None, None).await; + let err = result.expect_err("读体中断必须走 Err 通道(瞬时,前端 reject 后重试)"); + assert!(err.contains("Failed to read response"), "err={err}"); + handle.join().expect("server thread"); + } + + #[tokio::test] + async fn deterministic_http_401_stays_ok_with_auth_error() { + ensure_no_proxy_for_loopback(); + let (base_url, handle) = spawn_once_server(Some(http_response("401 Unauthorized", "{}"))); + + let quota = get_coding_plan_quota(&base_url, "k", None, None) + .await + .expect("鉴权失败是确定性失败,必须保持 Ok(success:false) 展示文案"); + assert!(!quota.success); + assert!(matches!(quota.credential_status, CredentialStatus::Expired)); + let err = quota.error.expect("应有错误文案"); + assert!(err.contains("Authentication failed (HTTP 401"), "err={err}"); + handle.join().expect("server thread"); + } + + #[tokio::test] + async fn deterministic_http_429_stays_ok_with_status_in_error() { + ensure_no_proxy_for_loopback(); + let (base_url, handle) = + spawn_once_server(Some(http_response("429 Too Many Requests", "slow down"))); + + let quota = get_coding_plan_quota(&base_url, "k", None, None) + .await + .expect("非 2xx 保持 Ok(success:false),状态码留在文案里交前端分类"); + assert!(!quota.success); + // 前端 isTransientUsageError 靠 /http\s+(\d{3})/ 提取状态码把 429 归瞬时, + // 文案格式是跨层契约,勿改。 + let err = quota.error.expect("应有错误文案"); + assert!(err.contains("HTTP 429"), "err={err}"); + handle.join().expect("server thread"); + } + + #[tokio::test] + async fn deterministic_invalid_json_body_stays_ok_with_parse_error() { + ensure_no_proxy_for_loopback(); + // 完整读到响应体但不是 JSON → is_decode → 确定性解析失败 + let (base_url, handle) = spawn_once_server(Some(http_response("200 OK", "not-json"))); + + let quota = get_coding_plan_quota(&base_url, "k", None, None) + .await + .expect("完整但非法的响应体是确定性失败,必须保持 Ok(success:false)"); + assert!(!quota.success); + let err = quota.error.expect("应有错误文案"); + assert!(err.contains("Failed to parse response"), "err={err}"); + handle.join().expect("server thread"); + } } diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 7388249b8..9e985cc21 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -57,6 +57,18 @@ pub(crate) async fn execute_and_format_usage_result( }) } Err(err) => { + // 瞬时传输失败(send 失败/超时、读体中断)以 Err 传播,让前端 invoke + // reject → react-query retry 并保留上次成功值;按错误 key 判定而非 + // 文案匹配。其余脚本/配置/HTTP 业务错误折叠成 success:false 展示文案。 + if let AppError::Localized { key, .. } = &err { + if matches!( + *key, + "usage_script.request_failed" | "usage_script.read_response_failed" + ) { + return Err(err); + } + } + let lang = settings::get_settings() .language .unwrap_or_else(|| "zh".to_string()); diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index dfe8d0a0e..c6c88cfd7 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -331,7 +331,11 @@ const KNOWN_TIERS: &[&str] = &[ ]; /// 查询 Claude 官方订阅额度 -async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { +/// +/// 瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject → retry + 保留上次 +/// 成功值);确定性失败(鉴权/非 2xx/响应体非法 JSON)返回 `Ok(success:false)`。 +/// codex/gemini 两个查询函数遵守同一约定。 +async fn query_claude_quota(access_token: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -345,42 +349,42 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => { - return SubscriptionQuota::error( - "claude", - CredentialStatus::Valid, - format!("Network error: {e}"), - ); - } + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "claude", CredentialStatus::Expired, format!("Authentication failed (HTTP {status}). Please re-login with Claude CLI."), - ); + )); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "claude", CredentialStatus::Valid, format!("API error (HTTP {status}): {body}"), - ); + )); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read API response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, Err(e) => { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "claude", CredentialStatus::Valid, format!("Failed to parse API response: {e}"), - ); + )); } }; @@ -435,7 +439,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { }) }); - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "claude".to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -444,7 +448,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { extra_usage, error: None, queried_at: Some(now_millis()), - } + }) } // ── Codex 凭据读取 ────────────────────────────────────── @@ -670,7 +674,7 @@ pub(crate) async fn query_codex_quota( account_id: Option<&str>, tool_label: &str, expired_message: &str, -) -> SubscriptionQuota { +) -> Result { let client = crate::proxy::http_client::get(); let mut req = client @@ -685,42 +689,40 @@ pub(crate) async fn query_codex_quota( let resp = match req.timeout(std::time::Duration::from_secs(15)).send().await { Ok(r) => r, - Err(e) => { - return SubscriptionQuota::error( - tool_label, - CredentialStatus::Valid, - format!("Network error: {e}"), - ); - } + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( tool_label, CredentialStatus::Expired, format!("{expired_message} (HTTP {status})"), - ); + )); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( tool_label, CredentialStatus::Valid, format!("API error (HTTP {status}): {body}"), - ); + )); } - let body: CodexUsageResponse = match resp.json().await { + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read API response: {e}")), + }; + let body: CodexUsageResponse = match serde_json::from_slice(&raw) { Ok(v) => v, Err(e) => { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( tool_label, CredentialStatus::Valid, format!("Failed to parse API response: {e}"), - ); + )); } }; @@ -746,7 +748,7 @@ pub(crate) async fn query_codex_quota( } } - SubscriptionQuota { + Ok(SubscriptionQuota { tool: tool_label.to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -755,7 +757,7 @@ pub(crate) async fn query_codex_quota( extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── Gemini 凭据读取 ────────────────────────────────────── @@ -1049,7 +1051,7 @@ fn classify_gemini_model(model_id: &str) -> &str { /// 两步 API 调用: /// 1. loadCodeAssist → 获取 cloudaicompanionProject /// 2. retrieveUserQuota → 获取按模型分桶的配额数据 -async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { +async fn query_gemini_quota(access_token: &str) -> Result { let client = crate::proxy::http_client::get(); // ── Step 1: loadCodeAssist 获取项目 ID ── @@ -1069,42 +1071,40 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { let load_resp = match load_resp { Ok(r) => r, - Err(e) => { - return SubscriptionQuota::error( - "gemini", - CredentialStatus::Valid, - format!("Network error (loadCodeAssist): {e}"), - ); - } + Err(e) => return Err(format!("Network error (loadCodeAssist): {e}")), }; let load_status = load_resp.status(); if load_status == reqwest::StatusCode::UNAUTHORIZED || load_status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Expired, format!("Authentication failed (HTTP {load_status}). Please re-login with Gemini CLI."), - ); + )); } if !load_status.is_success() { let body = load_resp.text().await.unwrap_or_default(); - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Valid, format!("loadCodeAssist failed (HTTP {load_status}): {body}"), - ); + )); } - let load_body: GeminiLoadCodeAssistResponse = match load_resp.json().await { + let load_raw = match load_resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read loadCodeAssist response: {e}")), + }; + let load_body: GeminiLoadCodeAssistResponse = match serde_json::from_slice(&load_raw) { Ok(v) => v, Err(e) => { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Valid, format!("Failed to parse loadCodeAssist response: {e}"), - ); + )); } }; @@ -1130,42 +1130,40 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { let quota_resp = match quota_resp { Ok(r) => r, - Err(e) => { - return SubscriptionQuota::error( - "gemini", - CredentialStatus::Valid, - format!("Network error (retrieveUserQuota): {e}"), - ); - } + Err(e) => return Err(format!("Network error (retrieveUserQuota): {e}")), }; let quota_status = quota_resp.status(); if quota_status == reqwest::StatusCode::UNAUTHORIZED || quota_status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Expired, format!("Authentication failed (HTTP {quota_status})."), - ); + )); } if !quota_status.is_success() { let body = quota_resp.text().await.unwrap_or_default(); - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Valid, format!("retrieveUserQuota failed (HTTP {quota_status}): {body}"), - ); + )); } - let quota_data: GeminiQuotaResponse = match quota_resp.json().await { + let quota_raw = match quota_resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read quota response: {e}")), + }; + let quota_data: GeminiQuotaResponse = match serde_json::from_slice("a_raw) { Ok(v) => v, Err(e) => { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Valid, format!("Failed to parse quota response: {e}"), - ); + )); } }; @@ -1213,7 +1211,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { tiers.sort_by_key(|t| sort_order(&t.name)); - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "gemini".to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -1222,12 +1220,16 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── 入口函数 ────────────────────────────────────────────── /// 查询指定 CLI 工具的官方订阅额度 +/// +/// 瞬时传输失败以 `Err` 传播(前端 reject → retry + 保留上次成功值)。Expired +/// 分支的"过期也试一把"重试同样用 `?` 传播瞬时错误——不能折叠成"已过期", +/// 否则一次网络抖动会被误报成确定性的凭据过期。 pub async fn get_subscription_quota(tool: &str) -> Result { match tool { "claude" => { @@ -1243,7 +1245,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result { // 即使过期也尝试调用 API(token 可能实际上仍有效) if let Some(token) = token { - let result = query_claude_quota(&token).await; + let result = query_claude_quota(&token).await?; if result.success { return Ok(result); } @@ -1256,7 +1258,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_claude_quota(&token).await) + query_claude_quota(&token).await } } } @@ -1279,7 +1281,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_codex_quota( + query_codex_quota( &token, account_id.as_deref(), "codex", "Authentication failed. Please re-login with Codex CLI.", ) - .await) + .await } } } @@ -1316,12 +1318,12 @@ pub async fn get_subscription_quota(tool: &str) -> Result Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_gemini_quota(&token).await) + query_gemini_quota(&token).await } } } diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index 397ab784c..293f5de33 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -65,6 +65,7 @@ const UsageFooter: React.FC = ({ const { data: usage, isFetching: loading, + isError, lastQueriedAt, refetch, } = useUsageQuery(providerId, appId, { @@ -86,11 +87,13 @@ const UsageFooter: React.FC = ({ return () => clearInterval(interval); }, [lastQueriedAt]); - // 只在启用用量查询且有数据时显示 - if (!usageEnabled || !usage) return null; + // 只在启用用量查询且有数据时显示。后端把瞬时传输失败转成了 reject:有缓存 + // 成功值时 react-query 保留 data 照常展示;首次查询就失败则 data 为空—— + // 此时(isError)仍要渲染失败态给出重试入口,否则 footer 整体消失、无从重查。 + if (!usageEnabled || (!usage && !isError)) return null; - // 错误状态 - if (!usage.success) { + // 错误状态(业务失败,或无缓存成功值的 reject) + if (!usage || !usage.success) { if (inline) { return (
@@ -115,7 +118,7 @@ const UsageFooter: React.FC = ({
- {usage.error || t("usage.queryFailed")} + {usage?.error || t("usage.queryFailed")}
{/* 刷新按钮 */} diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index e05521cff..27488f353 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -8,6 +8,7 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api"; import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot"; import { useSettingsQuery } from "@/lib/query"; import { resolveManagedAccountId } from "@/lib/authBinding"; +import { extractErrorMessage } from "@/utils/errorUtils"; import { useDarkMode } from "@/hooks/useDarkMode"; import { extractCodexBaseUrl, @@ -663,8 +664,10 @@ const UsageScriptModal: React.FC = ({ ); } } catch (error: any) { + // 后端命令 Err(String) 时 invoke 以裸字符串 reject(如瞬时网络失败), + // 直接读 .message 会得到 undefined。 toast.error( - `${t("usageScript.testFailed")}: ${error?.message || t("common.unknown")}`, + `${t("usageScript.testFailed")}: ${extractErrorMessage(error) || t("common.unknown")}`, { duration: 5000, }, diff --git a/src/lib/query/queries.ts b/src/lib/query/queries.ts index 28cba7556..0f4a380e0 100644 --- a/src/lib/query/queries.ts +++ b/src/lib/query/queries.ts @@ -19,6 +19,7 @@ import type { SessionMessage, } from "@/types"; import { usageKeys } from "@/lib/query/usage"; +import { extractErrorMessage } from "@/utils/errorUtils"; const sortProviders = ( providers: Record, @@ -100,34 +101,45 @@ export interface UseUsageQueryOptions { autoQueryInterval?: number; // 自动查询间隔(分钟),0 表示禁用 } -/** 最近一次成功的用量结果快照(keep-last-good 用)。 */ -export interface LastGoodUsage { - data: UsageResult; +/** keep-last-good 判定所需的最小结果形状(UsageResult / SubscriptionQuota 都满足)。 */ +export interface UsageLikeResult { + success: boolean; + error?: string | null; +} + +/** 最近一次成功的结果快照(keep-last-good 用)。 */ +export interface LastGoodSnapshot { + data: T; at: number; // 该成功结果的获取时刻(ms) } +/** 脚本路径 UsageResult 的快照类型(历史别名)。 */ +export type LastGoodUsage = LastGoodSnapshot; + /** 在最近一次成功后多久内,失败仍继续展示该成功值。 */ export const KEEP_LAST_GOOD_MS = 10 * 60 * 1000; // 10 分钟 /** - * 判断一次用量查询失败是否属于"瞬时/网络类"(可被 keep-last-good 短暂掩盖)。 + * 判断一次用量/额度查询失败是否属于"瞬时"(可被 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 …"` + * 注:后端已把纯传输层失败(send 失败/超时/读体中断)转成 Err → invoke reject, + * 不再折叠进 `Ok(success:false)`,到不了这里——那类失败由 [`resolveDisplayUsage`] + * 的 `rejected` 分支按同一窗口处理。本白名单主要兜仍以 `success:false` 返回的 + * **HTTP 5xx/429**,网络类文案匹配仅作兼容冗余。 * - * HTTP 状态:**5xx**(服务端错误,通常瞬时)归为 transient;**4xx**(鉴权/客户端 - * 错误,如 401/403/404/429)保持确定性,立即透出。 + * 采用**白名单**,失败安全——任何未识别的错误一律按"非瞬时"立即透出,绝不误掩盖 + * 确定性失败。需与后端错误文案保持同步: + * - 原生 balance/coding_plan/subscription:上游非 2xx → `"API error (HTTP …)"` + * - JS 脚本 usage_script:上游非 2xx → `"HTTP …"` + * + * HTTP 状态:**5xx**(服务端错误,通常瞬时)与 **429**(限流,稍后重试即可)归为 + * transient;其余 **4xx**(鉴权/客户端错误,如 401/403/404)保持确定性,立即透出。 */ -export function isTransientUsageError(result: UsageResult): boolean { +export function isTransientUsageError(result: UsageLikeResult): boolean { if (result.success) return false; const e = result.error?.toLowerCase() ?? ""; if (!e) return false; @@ -143,12 +155,12 @@ export function isTransientUsageError(result: UsageResult): boolean { return true; } - // HTTP 状态码:5xx 视为瞬时,4xx 视为确定性。错误文案里第一处 "HTTP " 即为 - // 上游状态码(原生 "API error (HTTP 500…)"、JS 脚本 "HTTP 500 …")。 + // HTTP 状态码:5xx 与 429(限流)视为瞬时,其余 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 (status >= 500 && status <= 599) || status === 429; } return false; @@ -156,30 +168,54 @@ export function isTransientUsageError(result: UsageResult): boolean { /** * Keep-last-good 的纯决策函数(无 ref、无时钟,`now` 注入以便测试)。 + * 对 `UsageResult`(脚本路径)与 `SubscriptionQuota`(订阅系 hooks)通用。 * - * 用量端点面向跨境/第三方,单次网络抖动会让后端返回 `Ok(success:false)` 当作"新数据" - * 覆盖掉上一次成功,使卡片瞬间翻红——这是"一会成功一会失败"观感的主因。 - * 策略:当失败是**瞬时/网络类**(见 [`isTransientUsageError`])且最近一次成功在 - * `keepMs` 内时,不抹掉它,继续展示该成功值,并把 `lastQueriedAt` 指向该成功的时刻 - * (相对时间自然走到"10 分钟前"后过期翻红);超出窗口、或从无成功记录时照常展示失败。 + * 策略:当失败是**瞬时**(见 [`isTransientUsageError`],即 5xx/429 等)且最近一次 + * 成功在 `keepMs` 内时,不抹掉它,继续展示该成功值,并把 `lastQueriedAt` 指向该 + * 成功的时刻(相对时间自然走到"10 分钟前"后过期翻红);超出窗口、或从无成功记录 + * 时照常展示失败。 * * **确定性失败**(鉴权/空 key/未知供应商/4xx 等)不仅立即透出,还会**清空 `lastGood`**: * 旧成功快照已不可信,否则随后一次网络抖动会把"配置/鉴权已失效"的旧额度重新复活。 * - * 注:会 reject 的传输层失败(Copilot/DB)react-query 本就保留上次 `data`,这里 - * 主要修的是 `Ok(success:false)` 这条覆盖路径。 + * 会 reject 的传输层失败(网络/超时/读体中断,后端已转 Err)由 `rejected` 标志 + * 处理:react-query 保留的上次成功 `data` 是**陈旧**的,不能当新鲜成功无限期展示 + * ——同样只在 `keepMs` 窗口内(锚定上次真实成功时刻)继续展示,超窗后 `data` + * 置空,由调用方合成失败占位透出。否则「彻底断网」反而比「单次 5xx」掩盖更久。 */ -export function resolveDisplayUsage( - raw: UsageResult | undefined, +export interface ResolveDisplayUsageOptions { + /** 本次查询是否以 reject 告终(invoke Err;react-query 会保留上次成功 data)。 */ + rejected?: boolean; + keepMs?: number; +} + +export function resolveDisplayUsage( + raw: T | undefined, dataUpdatedAt: number, - prevLastGood: LastGoodUsage | null, + prevLastGood: LastGoodSnapshot | null, now: number, - keepMs: number = KEEP_LAST_GOOD_MS, + options: ResolveDisplayUsageOptions = {}, ): { - data: UsageResult | undefined; + data: T | undefined; lastQueriedAt: number | null; - lastGood: LastGoodUsage | null; + lastGood: LastGoodSnapshot | null; } { + const { rejected = false, keepMs = KEEP_LAST_GOOD_MS } = options; + + if (rejected && raw?.success) { + // reject 时看到的"成功"是 react-query 保留的旧值。用它补种 lastGood——锚定 + // 上次真实成功时刻(dataUpdatedAt),且从 query 缓存派生,组件重挂丢失 ref + // 后窗口判定依然成立。 + const lastGood = { data: raw, at: dataUpdatedAt || now }; + if (now - lastGood.at < keepMs) { + return { data: raw, lastQueriedAt: lastGood.at, lastGood }; + } + // 超窗:陈旧成功不再展示(调用方合成失败占位)。快照保留而非清空——reject + // 属瞬时、不代表旧值失信(与 5xx 的"不清空"一致);窗口锚定成功时刻, + // 超窗后自然保持失效,直到下次成功刷新。 + return { data: undefined, lastQueriedAt: lastGood.at, lastGood }; + } + let lastGood = prevLastGood; if (raw?.success) { // 成功:刷新快照 @@ -231,9 +267,9 @@ export const useUsageQuery = ( refetchIntervalInBackground: true, // 后台也继续定时查询 refetchOnWindowFocus: false, // 用量查询面向跨境/第三方端点,单次网络抖动或瞬时 5xx 不应直接判失败。 - // 重试一次以吸收瞬时故障(与 useSubscriptionQuota 的 retry:1 保持一致)。 - // 注意:原生 balance/coding_plan 路径把网络错误折叠成 Ok(success:false), - // 这类不会触发 react-query 重试;本项主要覆盖会 reject 的传输层失败(Copilot/DB 等)。 + // 后端已把瞬时传输失败(网络/超时/读体中断)转成 Err → invoke reject, + // retry 在此真正生效;reject 保留的旧 data 与 Ok(success:false) 的 5xx/429 + // 一样,只在 resolveDisplayUsage 的 keep-last-good 窗口内继续展示。 retry: 1, retryDelay: 1500, staleTime, // 使用动态计算的缓存时间 @@ -248,12 +284,22 @@ export const useUsageQuery = ( query.dataUpdatedAt, lastGoodRef.current, Date.now(), + { rejected: query.isError }, ); lastGoodRef.current = lastGood; return { ...query, - data, + // reject 且无可展示值(首次查询即失败,或保留的旧成功已超窗):合成失败占位, + // 让 footer/卡片渲染失败态 + 重试入口,并透出 reject 的错误文案。 + data: + data ?? + (query.isError + ? { + success: false, + error: extractErrorMessage(query.error) || undefined, + } + : undefined), lastQueriedAt, }; }; diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index 7da5a7485..ec8e0c24f 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -1,9 +1,13 @@ -import { useQuery } from "@tanstack/react-query"; +import { useRef } from "react"; +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { subscriptionApi } from "@/lib/api/subscription"; import type { AppId } from "@/lib/api/types"; import type { ProviderMeta } from "@/types"; +import type { SubscriptionQuota } from "@/types/subscription"; import { resolveManagedAccountId } from "@/lib/authBinding"; import { PROVIDER_TYPES } from "@/config/constants"; +import { resolveDisplayUsage, type LastGoodSnapshot } from "./queries"; +import { extractErrorMessage } from "@/utils/errorUtils"; const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes @@ -12,6 +16,66 @@ export const subscriptionKeys = { quota: (appId: AppId) => [...subscriptionKeys.all, "quota", appId] as const, }; +/** + * reject 且无可展示值时的失败占位:首次查询就失败(data 为 undefined),或 + * react-query 保留的旧成功已超出 keep-last-good 窗口——合成一个失败结果,让 + * 订阅视图仍渲染「查询失败」+ 刷新按钮,而不是 footer 整体消失、无从手动重查。 + */ +const QUERY_REJECTED_PLACEHOLDER: SubscriptionQuota = { + tool: "", + credentialStatus: "valid", + credentialMessage: null, + success: false, + tiers: [], + extraUsage: null, + error: null, + queriedAt: null, +}; + +/** + * Keep-last-good:与 useUsageQuery 同一策略(resolveDisplayUsage)。 + * + * 后端对纯传输失败(网络/超时/读体中断)已 reject——react-query 保留上次 data + * 并触发 retry,但那份 data 是陈旧的:以 `rejected` 标志交给 resolveDisplayUsage + * 按同一窗口处理(窗口内继续展示,超窗透出失败),与仍以 `Ok(success:false)` + * 返回的瞬时失败(HTTP 5xx/429)行为一致。确定性失败(过期/鉴权/解析)不掩盖, + * 立即透出。 + * + * `scopeKey` 标识查询身份(appId / 绑定的账号 id):身份变化时丢弃旧快照, + * 避免用上一个账号的额度掩盖新账号的瞬时失败。 + */ +function useQuotaKeepLastGood( + query: UseQueryResult, + scopeKey: string, +) { + const lastGoodRef = useRef<{ + key: string; + snap: LastGoodSnapshot | null; + }>({ key: scopeKey, snap: null }); + if (lastGoodRef.current.key !== scopeKey) { + lastGoodRef.current = { key: scopeKey, snap: null }; + } + const { data, lastGood } = resolveDisplayUsage( + query.data, + query.dataUpdatedAt, + lastGoodRef.current.snap, + Date.now(), + { rejected: query.isError }, + ); + lastGoodRef.current.snap = lastGood; + return { + ...query, + data: + data ?? + (query.isError + ? { + ...QUERY_REJECTED_PLACEHOLDER, + error: extractErrorMessage(query.error) || null, + } + : undefined), + }; +} + export function useSubscriptionQuota( appId: AppId, enabled: boolean, @@ -23,7 +87,7 @@ export function useSubscriptionQuota( ? Math.max(autoQueryIntervalMinutes, 1) * 60 * 1000 : false; - return useQuery({ + const query = useQuery({ queryKey: subscriptionKeys.quota(appId), queryFn: () => subscriptionApi.getQuota(appId), enabled: enabled && ["claude", "codex", "gemini"].includes(appId), @@ -36,6 +100,8 @@ export function useSubscriptionQuota( : REFETCH_INTERVAL, retry: 1, }); + + return useQuotaKeepLastGood(query, appId); } export interface UseCodexOauthQuotaOptions { @@ -59,7 +125,7 @@ export function useCodexOauthQuota( ) { const { enabled = true, autoQuery = false } = options; const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH); - return useQuery({ + const query = useQuery({ queryKey: ["codex_oauth", "quota", accountId ?? "default"], queryFn: () => subscriptionApi.getCodexOauthQuota(accountId), enabled, @@ -69,4 +135,6 @@ export function useCodexOauthQuota( staleTime: REFETCH_INTERVAL, retry: 1, }); + + return useQuotaKeepLastGood(query, accountId ?? "default"); } diff --git a/tests/lib/keepLastGoodUsage.test.ts b/tests/lib/keepLastGoodUsage.test.ts index e696f9f26..2cbb4bf29 100644 --- a/tests/lib/keepLastGoodUsage.test.ts +++ b/tests/lib/keepLastGoodUsage.test.ts @@ -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 }); + }); });