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