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,
},
+80 -34
View File
@@ -19,6 +19,7 @@ import type {
SessionMessage,
} from "@/types";
import { usageKeys } from "@/lib/query/usage";
import { extractErrorMessage } from "@/utils/errorUtils";
const sortProviders = (
providers: Record<string, Provider>,
@@ -100,34 +101,45 @@ export interface UseUsageQueryOptions {
autoQueryInterval?: number; // 自动查询间隔(分钟),0 表示禁用
}
/** 最近一次成功的用量结果快照(keep-last-good )。 */
export interface LastGoodUsage {
data: UsageResult;
/** keep-last-good 判定所需的最小结果形状(UsageResult / SubscriptionQuota 都满足)。 */
export interface UsageLikeResult {
success: boolean;
error?: string | null;
}
/** 最近一次成功的结果快照(keep-last-good 用)。 */
export interface LastGoodSnapshot<T> {
data: T;
at: number; // 该成功结果的获取时刻(ms
}
/** 脚本路径 UsageResult 的快照类型(历史别名)。 */
export type LastGoodUsage = LastGoodSnapshot<UsageResult>;
/** 在最近一次成功后多久内,失败仍继续展示该成功值。 */
export const KEEP_LAST_GOOD_MS = 10 * 60 * 1000; // 10 分钟
/**
* 判断一次用量查询失败是否属于"瞬时/网络类"(可被 keep-last-good 短暂掩盖)。
* 判断一次用量/额度查询失败是否属于"瞬时"(可被 keep-last-good 短暂掩盖)。
*
* 仅瞬时失败才允许继续展示上一次成功;**确定性失败**(鉴权失败、空 API Key、
* 未知供应商、4xx、脚本/解析错误等)必须立即透出——用户改/删凭据后要马上看到,
* 否则会一直显示过期额度直到窗口结束。
*
* 采用**白名单**:只认后端稳定的网络类错误前缀 + HTTP 5xx,失败安全——任何未识别
* 的错误一律按"非瞬时"立即透出,绝不误掩盖确定性失败。需与后端错误文案保持同步:
* - 原生 balance/coding_plan/subscriptionreqwest send 失败/超时 → `"Network error: …"`
* 上游非 2xx → `"API error (HTTP <code>…)"`
* - JS 脚本 usage_script`request_failed` → "请求失败/Request failed"、
* `read_response_failed` → "读取响应失败/Failed to read response"(仅 zh/en 两种本地化);
* 上游非 2xx → `"HTTP <code> …"`
* 注:后端已把纯传输层失败(send 失败/超时/读体中断)转成 Err → invoke reject
* 不再折叠进 `Ok(success:false)`,到不了这里——那类失败由 [`resolveDisplayUsage`]
* 的 `rejected` 分支按同一窗口处理。本白名单主要兜仍以 `success:false` 返回的
* **HTTP 5xx/429**,网络类文案匹配仅作兼容冗余。
*
* HTTP 状态:**5xx**(服务端错误,通常瞬时)归为 transient;**4xx**(鉴权/客户端
* 错误,如 401/403/404/429)保持确定性,立即透出。
* 采用**白名单**,失败安全——任何未识别的错误一律按"非瞬时"立即透出,绝不误掩盖
* 确定性失败。需与后端错误文案保持同步:
* - 原生 balance/coding_plan/subscription:上游非 2xx → `"API error (HTTP <code>…)"`
* - JS 脚本 usage_script:上游非 2xx → `"HTTP <code> …"`
*
* HTTP 状态:**5xx**(服务端错误,通常瞬时)与 **429**(限流,稍后重试即可)归为
* transient;其余 **4xx**(鉴权/客户端错误,如 401/403/404)保持确定性,立即透出。
*/
export function isTransientUsageError(result: UsageResult): boolean {
export function isTransientUsageError(result: UsageLikeResult): boolean {
if (result.success) return false;
const e = result.error?.toLowerCase() ?? "";
if (!e) return false;
@@ -143,12 +155,12 @@ export function isTransientUsageError(result: UsageResult): boolean {
return true;
}
// HTTP 状态码:5xx 视为瞬时,4xx 视为确定性。错误文案里第一处 "HTTP <code>" 即为
// 上游状态码(原生 "API error (HTTP 500…)"、JS 脚本 "HTTP 500 …")。
// HTTP 状态码:5xx 与 429(限流)视为瞬时,其余 4xx 视为确定性。错误文案里第一处
// "HTTP <code>" 即为上游状态码(原生 "API error (HTTP 500…)"、JS 脚本 "HTTP 500 …")。
const httpMatch = e.match(/http\s+(\d{3})/);
if (httpMatch) {
const status = Number(httpMatch[1]);
return status >= 500 && status <= 599;
return (status >= 500 && status <= 599) || status === 429;
}
return false;
@@ -156,30 +168,54 @@ export function isTransientUsageError(result: UsageResult): boolean {
/**
* Keep-last-good 的纯决策函数(无 ref、无时钟,`now` 注入以便测试)。
* 对 `UsageResult`(脚本路径)与 `SubscriptionQuota`(订阅系 hooks)通用。
*
* 用量端点面向跨境/第三方,单次网络抖动会让后端返回 `Ok(success:false)` 当作"新数据"
* 覆盖掉上一次成功,使卡片瞬间翻红——这是"一会成功一会失败"观感的主因。
* 策略:当失败是**瞬时/网络类**(见 [`isTransientUsageError`])且最近一次成功在
* `keepMs` 内时,不抹掉它,继续展示该成功值,并把 `lastQueriedAt` 指向该成功的时刻
* (相对时间自然走到"10 分钟前"后过期翻红);超出窗口、或从无成功记录时照常展示失败。
* 策略:当失败是**瞬时**(见 [`isTransientUsageError`],即 5xx/429 等)且最近一次
* 成功在 `keepMs` 内时,不抹掉它,继续展示该成功值,并把 `lastQueriedAt` 指向该
* 成功的时刻(相对时间自然走到"10 分钟前"后过期翻红);超出窗口、或从无成功记录
* 时照常展示失败。
*
* **确定性失败**(鉴权/空 key/未知供应商/4xx 等)不仅立即透出,还会**清空 `lastGood`**
* 旧成功快照已不可信,否则随后一次网络抖动会把"配置/鉴权已失效"的旧额度重新复活。
*
* 注:会 reject 的传输层失败(Copilot/DBreact-query 本就保留上次 `data`,这里
* 主要修的是 `Ok(success:false)` 这条覆盖路径。
* 会 reject 的传输层失败(网络/超时/读体中断,后端已转 Err)由 `rejected` 标志
* 处理:react-query 保留的上次成功 `data` 是**陈旧**的,不能当新鲜成功无限期展示
* ——同样只在 `keepMs` 窗口内(锚定上次真实成功时刻)继续展示,超窗后 `data`
* 置空,由调用方合成失败占位透出。否则「彻底断网」反而比「单次 5xx」掩盖更久。
*/
export function resolveDisplayUsage(
raw: UsageResult | undefined,
export interface ResolveDisplayUsageOptions {
/** 本次查询是否以 reject 告终(invoke Errreact-query 会保留上次成功 data)。 */
rejected?: boolean;
keepMs?: number;
}
export function resolveDisplayUsage<T extends UsageLikeResult>(
raw: T | undefined,
dataUpdatedAt: number,
prevLastGood: LastGoodUsage | null,
prevLastGood: LastGoodSnapshot<T> | null,
now: number,
keepMs: number = KEEP_LAST_GOOD_MS,
options: ResolveDisplayUsageOptions = {},
): {
data: UsageResult | undefined;
data: T | undefined;
lastQueriedAt: number | null;
lastGood: LastGoodUsage | null;
lastGood: LastGoodSnapshot<T> | null;
} {
const { rejected = false, keepMs = KEEP_LAST_GOOD_MS } = options;
if (rejected && raw?.success) {
// reject 时看到的"成功"是 react-query 保留的旧值。用它补种 lastGood——锚定
// 上次真实成功时刻(dataUpdatedAt),且从 query 缓存派生,组件重挂丢失 ref
// 后窗口判定依然成立。
const lastGood = { data: raw, at: dataUpdatedAt || now };
if (now - lastGood.at < keepMs) {
return { data: raw, lastQueriedAt: lastGood.at, lastGood };
}
// 超窗:陈旧成功不再展示(调用方合成失败占位)。快照保留而非清空——reject
// 属瞬时、不代表旧值失信(与 5xx 的"不清空"一致);窗口锚定成功时刻,
// 超窗后自然保持失效,直到下次成功刷新。
return { data: undefined, lastQueriedAt: lastGood.at, lastGood };
}
let lastGood = prevLastGood;
if (raw?.success) {
// 成功:刷新快照
@@ -231,9 +267,9 @@ export const useUsageQuery = (
refetchIntervalInBackground: true, // 后台也继续定时查询
refetchOnWindowFocus: false,
// 用量查询面向跨境/第三方端点,单次网络抖动或瞬时 5xx 不应直接判失败。
// 重试一次以吸收瞬时故障(与 useSubscriptionQuota 的 retry:1 保持一致)。
// 注意:原生 balance/coding_plan 路径把网络错误折叠成 Ok(success:false)
// 这类不会触发 react-query 重试;本项主要覆盖会 reject 的传输层失败(Copilot/DB 等)
// 后端已把瞬时传输失败(网络/超时/读体中断)转成 Err → invoke reject
// retry 在此真正生效;reject 保留的旧 data 与 Ok(success:false) 的 5xx/429
// 一样,只在 resolveDisplayUsage 的 keep-last-good 窗口内继续展示
retry: 1,
retryDelay: 1500,
staleTime, // 使用动态计算的缓存时间
@@ -248,12 +284,22 @@ export const useUsageQuery = (
query.dataUpdatedAt,
lastGoodRef.current,
Date.now(),
{ rejected: query.isError },
);
lastGoodRef.current = lastGood;
return {
...query,
data,
// reject 且无可展示值(首次查询即失败,或保留的旧成功已超窗):合成失败占位,
// 让 footer/卡片渲染失败态 + 重试入口,并透出 reject 的错误文案。
data:
data ??
(query.isError
? {
success: false,
error: extractErrorMessage(query.error) || undefined,
}
: undefined),
lastQueriedAt,
};
};
+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");
}