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
+8 -5
View File
@@ -65,6 +65,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
const {
data: usage,
isFetching: loading,
isError,
lastQueriedAt,
refetch,
} = useUsageQuery(providerId, appId, {
@@ -86,11 +87,13 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
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 (
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
@@ -115,7 +118,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
<AlertCircle size={14} />
<span>{usage.error || t("usage.queryFailed")}</span>
<span>{usage?.error || t("usage.queryFailed")}</span>
</div>
{/* 刷新按钮 */}
+4 -1
View File
@@ -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<UsageScriptModalProps> = ({
);
}
} 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,
},