mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
|
||||
@@ -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
@@ -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/subscription:reqwest 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/DB)react-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 Err;react-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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user