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
+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
}
}
}