mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +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:
@@ -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