fix: giảm spam 429 từ Claude OAuth usage endpoint

- claudeAutoPing: cache resetAt in-mem, bỏ qua poll usage cho tới gần reset
- ProviderLimits: throttle auto-refresh Claude 3 phút, nút bấm tay vẫn refresh ngay
- claude.js: 429 ở OAuth usage → cooldown 3 phút, fallback legacy (không ảnh hưởng chat)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-06-17 11:42:20 +07:00
parent da667836cc
commit 79df34cad7
4 changed files with 40 additions and 8 deletions

View File

@@ -14,11 +14,19 @@ const CLAUDE_CONFIG = {
apiVersion: ANTHROPIC_API_VERSION,
};
/**
* Claude Usage - Primary: OAuth endpoint, Fallback: legacy settings/org endpoint
*/
// OAuth usage endpoint rate-limits (429); cool down per-token to stop hammering it.
// Only the quota endpoint is affected — chat with the same token still works.
const OAUTH_429_COOLDOWN_MS = 180000;
const oauthCooldown = new Map();
export async function getClaudeUsage(accessToken, proxyOptions = null) {
try {
// Skip OAuth usage call while this token is cooling down from a recent 429
const cooldownUntil = oauthCooldown.get(accessToken);
if (cooldownUntil && Date.now() < cooldownUntil) {
return await getClaudeUsageLegacy(accessToken, proxyOptions);
}
// Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens)
const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, {
method: "GET",
@@ -73,6 +81,11 @@ export async function getClaudeUsage(accessToken, proxyOptions = null) {
};
}
// Cool down OAuth usage polling after a 429 (quota endpoint only)
if (oauthResponse.status === 429) {
oauthCooldown.set(accessToken, Date.now() + OAUTH_429_COOLDOWN_MS);
}
// Fallback: legacy settings + org usage endpoint
console.warn(`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`);
return await getClaudeUsageLegacy(accessToken, proxyOptions);

View File

@@ -26,6 +26,7 @@ import {
setQuotaCache,
QUOTA_CACHE_KEY,
REFRESH_INTERVAL_MS,
CLAUDE_REFRESH_INTERVAL_MS,
DEPLETED_QUOTA_THRESHOLD,
AUTO_REFRESH_STORAGE_KEY,
CONNECTIONS_PAGE_SIZE,
@@ -131,6 +132,7 @@ export default function ProviderLimits() {
const intervalRef = useRef(null);
const countdownRef = useRef(null);
const tickCountRef = useRef(0);
const fetchConnections = useCallback(
async (targetPage = page) => {
@@ -401,12 +403,18 @@ export default function ProviderLimits() {
};
}, []);
const refreshAll = useCallback(async () => {
const refreshAll = useCallback(async (force = false) => {
if (refreshingAll) return;
setRefreshingAll(true);
setCountdown(60);
// Throttle Claude: poll its quota every Nth auto-tick (manual force bypasses)
const tick = (tickCountRef.current += 1);
const claudeEvery = Math.round(CLAUDE_REFRESH_INTERVAL_MS / REFRESH_INTERVAL_MS);
const shouldFetch = (conn) =>
force || conn.provider !== "claude" || tick % claudeEvery === 0;
try {
const visibleConnections = await fetchConnections(page);
@@ -419,7 +427,9 @@ export default function ProviderLimits() {
);
await Promise.all(
visibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
visibleConnections
.filter(shouldFetch)
.map((conn) => fetchQuota(conn.id, conn.provider)),
);
setLastUpdated(new Date());
@@ -539,7 +549,7 @@ export default function ProviderLimits() {
}
} else if (autoRefresh && hasHydratedAutoRefresh) {
// Resume auto-refresh when tab becomes visible
intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS);
intervalRef.current = setInterval(() => refreshAll(), REFRESH_INTERVAL_MS);
countdownRef.current = setInterval(() => {
setCountdown((prev) => (prev <= 1 ? 60 : prev - 1));
}, 1000);
@@ -866,7 +876,7 @@ export default function ProviderLimits() {
{/* Refresh all button */}
<button
type="button"
onClick={refreshAll}
onClick={() => refreshAll(true)}
disabled={refreshingAll}
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-black/10 px-2 text-xs text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5 disabled:opacity-50"
title="Refresh all"

View File

@@ -3,6 +3,8 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js";
// ─── Constants ───────────────────────────────────────────────────────────────
export const QUOTA_CACHE_KEY = "quotaCacheData";
export const REFRESH_INTERVAL_MS = 60000;
// Claude usage/quota endpoint rate-limits; poll it less often than other providers
export const CLAUDE_REFRESH_INTERVAL_MS = 180000;
export const DEPLETED_QUOTA_THRESHOLD = 5;
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
export const CONNECTIONS_PAGE_SIZE = 20;

View File

@@ -12,7 +12,7 @@ import { CLAUDE_AUTOPING_CONFIG } from "@/shared/constants/config";
const C = CLAUDE_AUTOPING_CONFIG;
const PING_URL = "https://api.anthropic.com/v1/messages?beta=true";
const g = (global.__claudeAutoPing ??= { interval: null, running: false });
const g = (global.__claudeAutoPing ??= { interval: null, running: false, resetCache: {} });
function buildProxyOptions(cfg) {
return {
@@ -43,6 +43,10 @@ async function sendPing(accessToken, proxyOptions) {
}
async function pingConnection(conn) {
// Cached resetAt is stable for the whole 5h window; skip usage poll until near reset
const cachedReset = g.resetCache[conn.id];
if (cachedReset && Date.now() < new Date(cachedReset).getTime() - C.refreshAheadMs) return;
const proxyCfg = await resolveConnectionProxyConfig(conn.providerSpecificData);
const proxyOptions = buildProxyOptions(proxyCfg);
@@ -60,6 +64,9 @@ async function pingConnection(conn) {
const resetAt = usage?.quotas?.[C.fiveHourKey]?.resetAt;
if (!resetAt) return;
// Cache resetAt to gate future ticks
g.resetCache[conn.id] = resetAt;
const resetMs = new Date(resetAt).getTime();
const now = Date.now();