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
+71 -3
View File
@@ -1,9 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { useRef } from "react";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { subscriptionApi } from "@/lib/api/subscription";
import type { AppId } from "@/lib/api/types";
import type { ProviderMeta } from "@/types";
import type { SubscriptionQuota } from "@/types/subscription";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { PROVIDER_TYPES } from "@/config/constants";
import { resolveDisplayUsage, type LastGoodSnapshot } from "./queries";
import { extractErrorMessage } from "@/utils/errorUtils";
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
@@ -12,6 +16,66 @@ export const subscriptionKeys = {
quota: (appId: AppId) => [...subscriptionKeys.all, "quota", appId] as const,
};
/**
* reject 且无可展示值时的失败占位:首次查询就失败(data 为 undefined),或
* react-query 保留的旧成功已超出 keep-last-good 窗口——合成一个失败结果,让
* 订阅视图仍渲染「查询失败」+ 刷新按钮,而不是 footer 整体消失、无从手动重查。
*/
const QUERY_REJECTED_PLACEHOLDER: SubscriptionQuota = {
tool: "",
credentialStatus: "valid",
credentialMessage: null,
success: false,
tiers: [],
extraUsage: null,
error: null,
queriedAt: null,
};
/**
* Keep-last-good:与 useUsageQuery 同一策略(resolveDisplayUsage)。
*
* 后端对纯传输失败(网络/超时/读体中断)已 reject——react-query 保留上次 data
* 并触发 retry,但那份 data 是陈旧的:以 `rejected` 标志交给 resolveDisplayUsage
* 按同一窗口处理(窗口内继续展示,超窗透出失败),与仍以 `Ok(success:false)`
* 返回的瞬时失败(HTTP 5xx/429)行为一致。确定性失败(过期/鉴权/解析)不掩盖,
* 立即透出。
*
* `scopeKey` 标识查询身份(appId / 绑定的账号 id):身份变化时丢弃旧快照,
* 避免用上一个账号的额度掩盖新账号的瞬时失败。
*/
function useQuotaKeepLastGood(
query: UseQueryResult<SubscriptionQuota>,
scopeKey: string,
) {
const lastGoodRef = useRef<{
key: string;
snap: LastGoodSnapshot<SubscriptionQuota> | null;
}>({ key: scopeKey, snap: null });
if (lastGoodRef.current.key !== scopeKey) {
lastGoodRef.current = { key: scopeKey, snap: null };
}
const { data, lastGood } = resolveDisplayUsage(
query.data,
query.dataUpdatedAt,
lastGoodRef.current.snap,
Date.now(),
{ rejected: query.isError },
);
lastGoodRef.current.snap = lastGood;
return {
...query,
data:
data ??
(query.isError
? {
...QUERY_REJECTED_PLACEHOLDER,
error: extractErrorMessage(query.error) || null,
}
: undefined),
};
}
export function useSubscriptionQuota(
appId: AppId,
enabled: boolean,
@@ -23,7 +87,7 @@ export function useSubscriptionQuota(
? Math.max(autoQueryIntervalMinutes, 1) * 60 * 1000
: false;
return useQuery({
const query = useQuery({
queryKey: subscriptionKeys.quota(appId),
queryFn: () => subscriptionApi.getQuota(appId),
enabled: enabled && ["claude", "codex", "gemini"].includes(appId),
@@ -36,6 +100,8 @@ export function useSubscriptionQuota(
: REFETCH_INTERVAL,
retry: 1,
});
return useQuotaKeepLastGood(query, appId);
}
export interface UseCodexOauthQuotaOptions {
@@ -59,7 +125,7 @@ export function useCodexOauthQuota(
) {
const { enabled = true, autoQuery = false } = options;
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
return useQuery({
const query = useQuery({
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
enabled,
@@ -69,4 +135,6 @@ export function useCodexOauthQuota(
staleTime: REFETCH_INTERVAL,
retry: 1,
});
return useQuotaKeepLastGood(query, accountId ?? "default");
}