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
+3 -2
View File
@@ -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) 可用模型列表
+21 -23
View File
@@ -381,32 +381,30 @@ pub async fn queryProviderUsage(
) -> Result<crate::provider::UsageResult, String> {
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
}
+22 -21
View File
@@ -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<SubscriptionQuota, String> {
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
}
+81 -45
View File
@@ -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<UsageResult, String> {
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<UsageResult, String> {
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<UsageResult, String> {
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<UsageResult, String> {
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<UsageResult, String> {
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<f64> {
// ── 公开入口 ────────────────────────────────────────────────
/// 查询余额。瞬时传输失败返回 `Err`(前端 reject → retry + 保留上次成功值),
/// 确定性失败返回 `Ok(success:false)`(见模块级文档)。
pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
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<UsageResult, S
}
};
let result = match provider {
match provider {
BalanceProvider::DeepSeek => 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)
}
}
+231 -59
View File
@@ -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<SubscriptionQuota, String> {
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<SubscriptionQuota, String> {
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<SubscriptionQuota, String> {
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<SubscriptionQuota, String> {
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<SubscriptionQuota, String> {
let region = volcengine_region(base_url);
let mut soft_errors: Vec<String> = Vec::new();
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
@@ -1071,7 +1104,8 @@ async fn query_volcengine(
// 1) Agent PlanGetAFPUsage
match volcengine_openapi_call(&region, 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_PROXYhttp_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>) -> (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");
}
}
+12
View File
@@ -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());
+75 -73
View File
@@ -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<SubscriptionQuota, String> {
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<SubscriptionQuota, String> {
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<SubscriptionQuota, String> {
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(&quota_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<SubscriptionQuota, String> {
match tool {
"claude" => {
@@ -1243,7 +1245,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
CredentialStatus::Expired => {
// 即使过期也尝试调用 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<SubscriptionQuota, Str
}
CredentialStatus::Valid => {
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<SubscriptionQuota, Str
"codex",
"Authentication failed. Please re-login with Codex CLI.",
)
.await;
.await?;
if result.success {
return Ok(result);
}
@@ -1292,13 +1294,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
}
CredentialStatus::Valid => {
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<SubscriptionQuota, Str
// Gemini access_token 仅 ~1h 有效,尝试用 refresh_token 刷新
if let Some(ref rt) = refresh_token {
if let Some(new_token) = refresh_gemini_token(rt).await {
return Ok(query_gemini_quota(&new_token).await);
return query_gemini_quota(&new_token).await;
}
}
// 刷新失败,尝试用旧 token
if let Some(ref token) = token {
let result = query_gemini_quota(token).await;
let result = query_gemini_quota(token).await?;
if result.success {
return Ok(result);
}
@@ -1334,7 +1336,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
}
CredentialStatus::Valid => {
let token = token.expect("token must be Some when status is Valid");
Ok(query_gemini_quota(&token).await)
query_gemini_quota(&token).await
}
}
}