mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 11:01:36 +08:00
fix(usage): reject transient transport failures so retry and keep-last-good work
Usage/quota queries frequently showed spurious "query failed" states that manual refresh could not clear (#3820). Root cause: all transport-level failures were folded into Ok(success:false), so react-query's retry never fired and the failure body poisoned the cache as regular data. Backend: - balance/coding_plan/subscription services now return Err for send failures and body-read failures (read body via bytes() before serde_json::from_slice; reqwest's json() wraps read errors as Decode, making error-kind checks on it dead code). Auth/4xx/parse errors stay Ok(success:false) and surface immediately. - Script path maps transient AppError keys (request_failed / read_response_failed) to Err; Volcengine adds a Transient call variant. - Command layer skips snapshot persistence, usage-cache-updated emit and tray refresh on Err so the cache bridge cannot overwrite retained data. - Expired-credential retry propagates transient errors instead of rewriting them as "OAuth token has expired". Frontend: - resolveDisplayUsage generalized to subscription quotas; 5th param is now an options object with a `rejected` flag: stale success data retained by react-query across rejections is re-anchored to dataUpdatedAt and expires through the same 10-minute keep-last-good window instead of being shown indefinitely (a total outage used to mask longer than a single 5xx). - useSubscriptionQuota / useCodexOauthQuota gain keep-last-good with per-scope snapshot reset; rejected queries with no displayable value synthesize a failure placeholder carrying the real error message so footers keep rendering a retry entry point. - HTTP 429 is classified transient (retry-later) alongside 5xx. - UsageScriptModal surfaces string rejections via extractErrorMessage. Tests: 6 backend behavior tests drive real HTTP against a local listener to pin the Err/Ok channel semantics; frontend suite extends keepLastGoodUsage coverage (405 passing).
This commit is contained in:
@@ -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("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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user