From 52d9b515451cf2c187a0049564d5cba4593745ce Mon Sep 17 00:00:00 2001 From: 9nunya <119450941+9nunya@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:54:34 -0500 Subject: [PATCH 1/8] fix: improve account rotation with request timeouts and interruption safety - Implement AbortSignal.timeout (180s default) to prevent hanging on stuck accounts - Fix TUI/web server freeze by immediately exiting request loop on user interrupt - Add proactive pool quota refresh when majority of accounts are rate-limited - MARK unhealthy accounts on timeout to trigger automatic rotation --- package-lock.json | 22 +++++++++--------- src/plugin.ts | 46 ++++++++++++++++++++++++++++++++++--- src/plugin/accounts.ts | 20 ++++++++++++++++ src/plugin/config/schema.ts | 10 ++++++++ src/plugin/quota.ts | 35 ++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index e4f8a6b1..9d9e9c88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-antigravity-auth", - "version": "1.3.3-beta.2", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-antigravity-auth", - "version": "1.3.3-beta.2", + "version": "1.6.0", "license": "MIT", "dependencies": { "@openauthjs/openauth": "^0.4.3", @@ -656,7 +656,6 @@ "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==", "license": "MIT", - "peer": true, "dependencies": { "@oslojs/binary": "1.0.0" } @@ -665,15 +664,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz", "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@oslojs/crypto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz", "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==", "license": "MIT", - "peer": true, "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" @@ -683,15 +680,13 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@oslojs/jwt": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz", "integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==", "license": "MIT", - "peer": true, "dependencies": { "@oslojs/encoding": "0.4.1" } @@ -700,8 +695,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz", "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -1066,6 +1060,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1227,6 +1222,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -1923,6 +1919,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2365,6 +2362,7 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2463,6 +2461,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -2678,6 +2677,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/plugin.ts b/src/plugin.ts index ee8b6240..ca7abb52 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -44,7 +44,7 @@ import { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackof import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker"; import { loadConfig, initRuntimeConfig, type AntigravityConfig } from "./plugin/config"; import { createSessionRecoveryHook, getRecoverySuccessToast } from "./plugin/recovery"; -import { checkAccountsQuota } from "./plugin/quota"; +import { checkAccountsQuota, triggerAsyncQuotaRefreshForAll } from "./plugin/quota"; import { initDiskSignatureCache } from "./plugin/cache"; import { createProactiveRefreshQueue, type ProactiveRefreshQueue } from "./plugin/refresh-queue"; import { initLogger, createLogger } from "./plugin/logger"; @@ -90,7 +90,7 @@ let softQuotaToastShown = false; let rateLimitToastShown = false; // Module-level reference to AccountManager for access from auth.login -let activeAccountManager: import("./plugin/accounts").AccountManager | null = null; +let activeAccountManager: AccountManager | null = null; function cleanupToastCooldowns(): void { if (rateLimitToastCooldowns.size > MAX_TOAST_COOLDOWN_ENTRIES) { @@ -2022,7 +2022,23 @@ export const createAntigravityPlugin = (providerId: string) => async ( tokenConsumed = getTokenTracker().consume(account.index); } - const response = await fetch(prepared.request, prepared.init); + // Check if we should proactively refresh all quotas + if (accountManager.shouldRefreshAllQuotas()) { + pushDebug("proactive-quota-refresh: pool is mostly blocked, refreshing all"); + void triggerAsyncQuotaRefreshForAll(accountManager, client, providerId); + } + + // Create a combined signal for timeout and user abort + const timeoutMs = (config.request_timeout_seconds ?? 180) * 1000; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const combinedSignal = abortSignal + ? (AbortSignal as any).any([abortSignal, timeoutSignal]) + : timeoutSignal; + + const response = await fetch(prepared.request, { + ...prepared.init, + signal: combinedSignal + }); pushDebug(`status=${response.status} ${response.statusText}`); @@ -2403,6 +2419,30 @@ export const createAntigravityPlugin = (providerId: string) => async ( tokenConsumed = false; } + // [CRITICAL] Check for AbortError from user vs timeout + if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) { + if (abortSignal?.aborted) { + // User pressed ESC - stop everything immediately to prevent spin loop and memory leak + pushDebug("user-interrupted: stopping request loop"); + throw error; + } + + // This was a request timeout (stuck account) + const timeoutSec = config.request_timeout_seconds ?? 180; + pushDebug(`request-timeout: account ${account.index} stuck for ${timeoutSec}s, rotating`); + getHealthTracker().recordFailure(account.index); + accountManager.markAccountCoolingDown(account, 60000, "network-error"); + + await showToast( + `⏳ Account stuck (${timeoutSec}s). Rotating to next available...`, + "warning" + ); + + shouldSwitchAccount = true; + lastError = error; + break; + } + // Handle recoverable thinking errors - retry with forced recovery if (error instanceof Error && error.message === "THINKING_RECOVERY_NEEDED") { // Only retry once with forced recovery to avoid infinite loops diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index 0a91326c..db139007 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -1160,9 +1160,29 @@ export class AccountManager { if (account) { account.cachedQuota = quotaGroups; account.cachedQuotaUpdatedAt = nowMs(); + this.requestSaveToDisk(); } } + /** + * Heuristic to determine if we should refresh all quotas in the pool. + * Triggers when the pool is mostly blocked or unhealthy. + */ + shouldRefreshAllQuotas(): boolean { + const enabled = this.getEnabledAccounts(); + if (enabled.length === 0) return false; + + const now = nowMs(); + // Refresh if more than 75% of enabled accounts are marked as rate-limited or over soft quota + const blockedCount = enabled.filter(acc => { + const isRateLimited = Object.values(acc.rateLimitResetTimes).some(t => t !== undefined && t > now); + const isCoolingDown = acc.coolingDownUntil !== undefined && acc.coolingDownUntil > now; + return isRateLimited || isCoolingDown; + }).length; + + return (blockedCount / enabled.length) >= 0.75; + } + isAccountOverSoftQuota(account: ManagedAccount, family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null): boolean { return isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, model); } diff --git a/src/plugin/config/schema.ts b/src/plugin/config/schema.ts index c29e7e38..e83d329d 100644 --- a/src/plugin/config/schema.ts +++ b/src/plugin/config/schema.ts @@ -267,6 +267,15 @@ export const AntigravityConfigSchema = z.object({ * @default 300 (5 minutes) */ max_rate_limit_wait_seconds: z.number().min(0).max(3600).default(300), + + /** + * Maximum time in seconds for a single API request before timing out. + * When a request times out, the account is marked unhealthy and the + * plugin automatically rotates to the next available account. + * + * @default 180 (3 minutes) + */ + request_timeout_seconds: z.number().min(30).max(1800).default(180), /** * @deprecated Kept only for backward compatibility. @@ -464,6 +473,7 @@ export const DEFAULT_CONFIG: AntigravityConfig = { proactive_refresh_buffer_seconds: 1800, proactive_refresh_check_interval_seconds: 300, max_rate_limit_wait_seconds: 300, + request_timeout_seconds: 180, quota_fallback: false, cli_first: false, account_selection_strategy: 'hybrid', diff --git a/src/plugin/quota.ts b/src/plugin/quota.ts index 895dbc8e..64ae234a 100644 --- a/src/plugin/quota.ts +++ b/src/plugin/quota.ts @@ -10,8 +10,11 @@ import { refreshAccessToken } from "./token"; import { getModelFamily } from "./transform/model-resolver"; import type { PluginClient, OAuthAuthDetails } from "./types"; import type { AccountMetadataV3 } from "./storage"; +import type { AccountManager } from "./accounts"; const FETCH_TIMEOUT_MS = 10000; +const POOL_REFRESH_COOLDOWN_MS = 60000; // 1 minute +let lastPoolRefreshTime = 0; export type QuotaGroup = "claude" | "gemini-pro" | "gemini-flash"; @@ -393,3 +396,35 @@ export async function checkAccountsQuota( logQuotaFetch("complete", accounts.length, `ok=${results.filter(r => r.status === "ok").length} errors=${results.filter(r => r.status === "error").length}`); return results; } + +/** + * Proactively refreshes quotas for all accounts in the background. + * Updates the account manager with new quota data. + */ +export async function triggerAsyncQuotaRefreshForAll( + accountManager: AccountManager, + client: PluginClient, + providerId: string, +): Promise { + const now = Date.now(); + if (now - lastPoolRefreshTime < POOL_REFRESH_COOLDOWN_MS) { + return; + } + lastPoolRefreshTime = now; + + try { + const accountsMetadata = accountManager.getAccountsForQuotaCheck(); + if (accountsMetadata.length === 0) return; + + const results = await checkAccountsQuota(accountsMetadata, client, providerId); + + for (const result of results) { + if (result.status === "ok" && result.quota) { + accountManager.updateQuotaCache(result.index, result.quota.groups); + } + } + } catch (error) { + // Proactive refresh is best-effort - log and ignore + console.error("[ProactiveQuota] Failed to refresh all quotas", error); + } +} From 3757ec3202400acdcd6aa2a2829b07e868699d46 Mon Sep 17 00:00:00 2001 From: 9nunya <119450941+9nunya@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:02:26 -0500 Subject: [PATCH 2/8] fix: add AbortSignal.any polyfill for Node.js <20.17.0 compatibility --- src/plugin.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/plugin.ts b/src/plugin.ts index ca7abb52..4694788d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -80,6 +80,32 @@ let childSessionParentID: string | undefined = undefined; const log = createLogger("plugin"); +/** + * [Node.js Compatibility Polyfill] + * AbortSignal.any() was added in Node 20.17.0. + * This project supports Node >= 20.0.0, so we provide a polyfill + * for older 20.x releases. + */ +if (typeof (AbortSignal as any).any !== "function") { + (AbortSignal as any).any = function (signals: AbortSignal[]): AbortSignal { + const controller = new AbortController(); + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener( + "abort", + () => { + controller.abort(signal.reason); + }, + { once: true } + ); + } + return controller.signal; + }; +} + // Module-level toast debounce to persist across requests (fixes toast spam) const rateLimitToastCooldowns = new Map(); const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000; From f12df55ec0c23211229e2944e161b8c36a2e5c4d Mon Sep 17 00:00:00 2001 From: 9nunya <119450941+9nunya@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:09:09 -0500 Subject: [PATCH 3/8] fix: refine proactive quota refresh and stabilize identifier lookup - AccountManager.shouldRefreshAllQuotas now includes soft quota checks in its blockedCount calculation - AccountManager.updateQuotaCache now uses stable refreshToken instead of stale positional index - Quota results now include refreshToken for reliable identification during async refreshes --- src/plugin.ts | 7 ++- src/plugin/accounts.test.ts | 85 ++++++++++++++++++++++++++++++------- src/plugin/accounts.ts | 17 ++++++-- src/plugin/quota.ts | 7 ++- 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 4694788d..fe23cc21 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -183,9 +183,8 @@ async function triggerAsyncQuotaRefreshForAccount( const results = await checkAccountsQuota([singleAccount], client, providerId); - if (results[0]?.status === "ok" && results[0]?.quota?.groups) { - accountManager.updateQuotaCache(accountIndex, results[0].quota.groups); - accountManager.requestSaveToDisk(); + if (results[0]?.status === "ok" && results[0]?.quota?.groups && results[0]?.refreshToken) { + accountManager.updateQuotaCache(results[0].refreshToken, results[0].quota.groups); } } catch (err) { log.debug(`quota-refresh-failed email=${accountKey}`, { error: String(err) }); @@ -2049,7 +2048,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( } // Check if we should proactively refresh all quotas - if (accountManager.shouldRefreshAllQuotas()) { + if (accountManager.shouldRefreshAllQuotas(family, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, model)) { pushDebug("proactive-quota-refresh: pool is mostly blocked, refreshing all"); void triggerAsyncQuotaRefreshForAll(accountManager, client, providerId); } diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index ad0b62b7..9626eedf 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -1580,7 +1580,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r2"); @@ -1596,7 +1596,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0.15, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.15, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r1"); @@ -1612,7 +1612,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0.01, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.01, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 100); expect(account?.parts.refreshToken).toBe("r1"); @@ -1629,8 +1629,8 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); - manager.updateQuotaCache(1, { claude: { remainingFraction: 0.08, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, modelCount: 1 } }); + manager.updateQuotaCache("r2", { claude: { remainingFraction: 0.08, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account).toBeNull(); @@ -1647,7 +1647,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "round-robin", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r2"); @@ -1679,7 +1679,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r2"); @@ -1698,7 +1698,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, modelCount: 1 } }); vi.setSystemTime(new Date(11 * 60 * 1000)); @@ -1738,7 +1738,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0.15, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.15, modelCount: 1 } }); const waitMs = manager.getMinWaitTimeForSoftQuota("claude", 90, 10 * 60 * 1000); expect(waitMs).toBe(0); @@ -1754,7 +1754,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, modelCount: 1 } }); const waitMs = manager.getMinWaitTimeForSoftQuota("claude", 90, 10 * 60 * 1000); expect(waitMs).toBeNull(); @@ -1773,7 +1773,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, resetTime: "2026-01-28T15:00:00Z", @@ -1800,7 +1800,7 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, resetTime: "2026-01-28T15:00:00Z", @@ -1828,10 +1828,10 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - manager.updateQuotaCache(0, { + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, resetTime: "2026-01-28T15:00:00Z", modelCount: 1 } }); - manager.updateQuotaCache(1, { + manager.updateQuotaCache("r2", { claude: { remainingFraction: 0.08, resetTime: "2026-01-28T12:00:00Z", modelCount: 1 } }); @@ -1863,6 +1863,59 @@ describe("resolveQuotaGroup", () => { it("model takes precedence over family", () => { // Even if family says claude, model determines the quota group expect(resolveQuotaGroup("gemini", "gemini-2.5-flash")).toBe("gemini-flash"); - expect(resolveQuotaGroup("gemini", "gemini-3-pro")).toBe("gemini-pro"); + expect(resolveQuotaGroup("gemini", "gemini-3-pro")).toBe("gemini-pro"); + }); }); -}); + + describe("shouldRefreshAllQuotas", () => { + it("returns true when more than 75% accounts are rate-limited", () => { + const stored: AccountStorageV4 = { + version: 4, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { claude: Date.now() + 100000 } }, + { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0, rateLimitResetTimes: { claude: Date.now() + 100000 } }, + { refreshToken: "r3", projectId: "p3", addedAt: 3, lastUsed: 0, rateLimitResetTimes: { claude: Date.now() + 100000 } }, + { refreshToken: "r4", projectId: "p4", addedAt: 4, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + expect(manager.shouldRefreshAllQuotas()).toBe(true); // 3/4 = 75% + }); + + it("returns true when accounts are over soft quota", () => { + const stored: AccountStorageV4 = { + version: 4, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, + { refreshToken: "r3", projectId: "p3", addedAt: 3, lastUsed: 0 }, + { refreshToken: "r4", projectId: "p4", addedAt: 4, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + manager.updateQuotaCache("r1", { claude: { remainingFraction: 0.05, modelCount: 1 } }); + manager.updateQuotaCache("r2", { claude: { remainingFraction: 0.05, modelCount: 1 } }); + manager.updateQuotaCache("r3", { claude: { remainingFraction: 0.05, modelCount: 1 } }); + + expect(manager.shouldRefreshAllQuotas("claude", 90)).toBe(true); + }); + + it("returns false when pool is healthy", () => { + const stored: AccountStorageV4 = { + version: 4, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + expect(manager.shouldRefreshAllQuotas()).toBe(false); + }); + }); + diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index db139007..7aaff516 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -1155,8 +1155,11 @@ export class AccountManager { return [...account.fingerprintHistory]; } - updateQuotaCache(accountIndex: number, quotaGroups: Partial>): void { - const account = this.accounts[accountIndex]; + updateQuotaCache( + refreshToken: string, + quotaGroups: Partial> + ): void { + const account = this.accounts.find(a => a.parts.refreshToken === refreshToken); if (account) { account.cachedQuota = quotaGroups; account.cachedQuotaUpdatedAt = nowMs(); @@ -1168,7 +1171,12 @@ export class AccountManager { * Heuristic to determine if we should refresh all quotas in the pool. * Triggers when the pool is mostly blocked or unhealthy. */ - shouldRefreshAllQuotas(): boolean { + shouldRefreshAllQuotas( + family?: ModelFamily, + thresholdPercent: number = 100, + cacheTtlMs: number = 10 * 60 * 1000, + model?: string | null + ): boolean { const enabled = this.getEnabledAccounts(); if (enabled.length === 0) return false; @@ -1177,7 +1185,8 @@ export class AccountManager { const blockedCount = enabled.filter(acc => { const isRateLimited = Object.values(acc.rateLimitResetTimes).some(t => t !== undefined && t > now); const isCoolingDown = acc.coolingDownUntil !== undefined && acc.coolingDownUntil > now; - return isRateLimited || isCoolingDown; + const isOverSoftQuota = family ? isOverSoftQuotaThreshold(acc, family, thresholdPercent, cacheTtlMs, model) : false; + return isRateLimited || isCoolingDown || isOverSoftQuota; }).length; return (blockedCount / enabled.length) >= 0.75; diff --git a/src/plugin/quota.ts b/src/plugin/quota.ts index 64ae234a..61a18068 100644 --- a/src/plugin/quota.ts +++ b/src/plugin/quota.ts @@ -57,6 +57,7 @@ export type AccountQuotaStatus = "ok" | "disabled" | "error"; export interface AccountQuotaResult { index: number; email?: string; + refreshToken?: string; status: AccountQuotaStatus; error?: string; disabled?: boolean; @@ -369,6 +370,7 @@ export async function checkAccountsQuota( results.push({ index, email: account.email, + refreshToken: account.refreshToken, status: "ok", disabled, quota: quotaResult, @@ -385,6 +387,7 @@ export async function checkAccountsQuota( results.push({ index, email: account.email, + refreshToken: account.refreshToken, status: "error", disabled, error: error instanceof Error ? error.message : String(error), @@ -419,8 +422,8 @@ export async function triggerAsyncQuotaRefreshForAll( const results = await checkAccountsQuota(accountsMetadata, client, providerId); for (const result of results) { - if (result.status === "ok" && result.quota) { - accountManager.updateQuotaCache(result.index, result.quota.groups); + if (result.status === "ok" && result.quota && result.refreshToken) { + accountManager.updateQuotaCache(result.refreshToken, result.quota.groups); } } } catch (error) { From b1306a485b0877a163a668176b0d2596affb34c6 Mon Sep 17 00:00:00 2001 From: 9nunya <119450941+9nunya@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:12:38 -0500 Subject: [PATCH 4/8] fix: increase default timeout and implement streaming multiplier - Increase default request_timeout_seconds to 600s (10 mins) - Apply 3x multiplier for streaming requests (max 30 mins) to prevent premature timeouts on active streams - Fix actualTimeoutSec logic in catch block by moving declaration outside try scope --- src/plugin.ts | 18 +++++++++++++----- src/plugin/config/schema.ts | 6 +++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index fe23cc21..fb914fd5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1988,6 +1988,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( continue; } + let effectiveTimeoutMs = (config.request_timeout_seconds ?? 600) * 1000; + try { const prepared = prepareAntigravityRequest( input, @@ -2054,8 +2056,14 @@ export const createAntigravityPlugin = (providerId: string) => async ( } // Create a combined signal for timeout and user abort - const timeoutMs = (config.request_timeout_seconds ?? 180) * 1000; - const timeoutSignal = AbortSignal.timeout(timeoutMs); + const timeoutMs = (config.request_timeout_seconds ?? 600) * 1000; + // For streaming, we allow up to 3x the timeout (max 30 mins) to account for long generations + // while still catching truly "stuck" connections. + effectiveTimeoutMs = prepared.streaming + ? Math.min(timeoutMs * 3, 1800000) + : timeoutMs; + + const timeoutSignal = AbortSignal.timeout(effectiveTimeoutMs); const combinedSignal = abortSignal ? (AbortSignal as any).any([abortSignal, timeoutSignal]) : timeoutSignal; @@ -2453,13 +2461,13 @@ export const createAntigravityPlugin = (providerId: string) => async ( } // This was a request timeout (stuck account) - const timeoutSec = config.request_timeout_seconds ?? 180; - pushDebug(`request-timeout: account ${account.index} stuck for ${timeoutSec}s, rotating`); + const actualTimeoutSec = Math.round(effectiveTimeoutMs / 1000); + pushDebug(`request-timeout: account ${account.index} stuck for ${actualTimeoutSec}s, rotating`); getHealthTracker().recordFailure(account.index); accountManager.markAccountCoolingDown(account, 60000, "network-error"); await showToast( - `⏳ Account stuck (${timeoutSec}s). Rotating to next available...`, + `⏳ Account stuck (${actualTimeoutSec}s). Rotating to next available...`, "warning" ); diff --git a/src/plugin/config/schema.ts b/src/plugin/config/schema.ts index e83d329d..bb75a6a2 100644 --- a/src/plugin/config/schema.ts +++ b/src/plugin/config/schema.ts @@ -273,9 +273,9 @@ export const AntigravityConfigSchema = z.object({ * When a request times out, the account is marked unhealthy and the * plugin automatically rotates to the next available account. * - * @default 180 (3 minutes) + * @default 600 (10 minutes) */ - request_timeout_seconds: z.number().min(30).max(1800).default(180), + request_timeout_seconds: z.number().min(30).max(3600).default(600), /** * @deprecated Kept only for backward compatibility. @@ -473,7 +473,7 @@ export const DEFAULT_CONFIG: AntigravityConfig = { proactive_refresh_buffer_seconds: 1800, proactive_refresh_check_interval_seconds: 300, max_rate_limit_wait_seconds: 300, - request_timeout_seconds: 180, + request_timeout_seconds: 600, quota_fallback: false, cli_first: false, account_selection_strategy: 'hybrid', From 203d4db4bcbaa80a32be4ef325fb22b538e4365e Mon Sep 17 00:00:00 2001 From: 9nunya <119450941+9nunya@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:13:29 -0500 Subject: [PATCH 5/8] fix: prioritize user abort signal check in request loop catch block --- src/plugin.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index fb914fd5..44fdfb5f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2452,14 +2452,15 @@ export const createAntigravityPlugin = (providerId: string) => async ( tokenConsumed = false; } - // [CRITICAL] Check for AbortError from user vs timeout + // [CRITICAL] Check for user interruption FIRST before any error classification + if (abortSignal?.aborted) { + // User pressed ESC - stop everything immediately to prevent spin loop and memory leak + pushDebug("user-interrupted: stopping request loop"); + throw error; + } + + // Check for timeout errors if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) { - if (abortSignal?.aborted) { - // User pressed ESC - stop everything immediately to prevent spin loop and memory leak - pushDebug("user-interrupted: stopping request loop"); - throw error; - } - // This was a request timeout (stuck account) const actualTimeoutSec = Math.round(effectiveTimeoutMs / 1000); pushDebug(`request-timeout: account ${account.index} stuck for ${actualTimeoutSec}s, rotating`); From a4745c1ee32e183bc4f8e604f2832e02f095180f Mon Sep 17 00:00:00 2001 From: 9nunya <119450941+9nunya@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:27:45 -0500 Subject: [PATCH 6/8] fix: refine proactive refresh logic and persist timeout cooldowns - Ensure proactive quota refresh only triggers on the first endpoint attempt - Persist account cooldown state immediately after a request timeout - Refine shouldRefreshAllQuotas to use family-specific rate limiting check - Update test descriptions to match implementation (75% or more) --- src/plugin.ts | 7 +++++-- src/plugin/accounts.test.ts | 2 +- src/plugin/accounts.ts | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 44fdfb5f..c3324b03 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2049,8 +2049,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( tokenConsumed = getTokenTracker().consume(account.index); } - // Check if we should proactively refresh all quotas - if (accountManager.shouldRefreshAllQuotas(family, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, model)) { + // Check if we should proactively refresh all quotas (only on first endpoint attempt) + if (i === 0 && accountManager.shouldRefreshAllQuotas(family, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, model)) { pushDebug("proactive-quota-refresh: pool is mostly blocked, refreshing all"); void triggerAsyncQuotaRefreshForAll(accountManager, client, providerId); } @@ -2467,6 +2467,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( getHealthTracker().recordFailure(account.index); accountManager.markAccountCoolingDown(account, 60000, "network-error"); + // Persist cooldown immediately so it survives restarts + await accountManager.saveToDisk(); + await showToast( `⏳ Account stuck (${actualTimeoutSec}s). Rotating to next available...`, "warning" diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index 9626eedf..8af64dbf 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -1868,7 +1868,7 @@ describe("resolveQuotaGroup", () => { }); describe("shouldRefreshAllQuotas", () => { - it("returns true when more than 75% accounts are rate-limited", () => { + it("returns true when 75% or more accounts are rate-limited", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index 7aaff516..16f5ebb2 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -1181,9 +1181,11 @@ export class AccountManager { if (enabled.length === 0) return false; const now = nowMs(); - // Refresh if more than 75% of enabled accounts are marked as rate-limited or over soft quota + // Refresh if 75% or more of enabled accounts are marked as rate-limited or over soft quota const blockedCount = enabled.filter(acc => { - const isRateLimited = Object.values(acc.rateLimitResetTimes).some(t => t !== undefined && t > now); + const isRateLimited = family + ? isRateLimitedForFamily(acc, family, model) + : Object.values(acc.rateLimitResetTimes).some(t => t !== undefined && t > now); const isCoolingDown = acc.coolingDownUntil !== undefined && acc.coolingDownUntil > now; const isOverSoftQuota = family ? isOverSoftQuotaThreshold(acc, family, thresholdPercent, cacheTtlMs, model) : false; return isRateLimited || isCoolingDown || isOverSoftQuota; From 506698bb7e6644aafb6f1294b8e54e7ad4d4a7b4 Mon Sep 17 00:00:00 2001 From: 9nunya <119450941+9nunya@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:34:51 -0500 Subject: [PATCH 7/8] fix: wrap account persistence in try-catch to prevent rotation failure on disk errors --- src/plugin.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugin.ts b/src/plugin.ts index c3324b03..d24eb5ca 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2468,7 +2468,11 @@ export const createAntigravityPlugin = (providerId: string) => async ( accountManager.markAccountCoolingDown(account, 60000, "network-error"); // Persist cooldown immediately so it survives restarts - await accountManager.saveToDisk(); + try { + await accountManager.saveToDisk(); + } catch (saveError) { + log.error("failed-to-persist-timeout-cooldown", { error: String(saveError) }); + } await showToast( `⏳ Account stuck (${actualTimeoutSec}s). Rotating to next available...`, From 2b9b575bb2346e3fb5265b5562da9d890e079da6 Mon Sep 17 00:00:00 2001 From: 9nunya <119450941+9nunya@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:39:57 -0500 Subject: [PATCH 8/8] fix: enhance AbortSignal compatibility and fix listener leaks - Improve AbortSignal.any polyfill with proper listener cleanup - Add AbortSignal.timeout guard and fallback for older Node.js versions - Implement mergeAbortSignals helper for safe signal combination - Add exhaustive listener cleanup on signal abortion --- src/plugin.ts | 69 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index d24eb5ca..313bf201 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -89,23 +89,62 @@ const log = createLogger("plugin"); if (typeof (AbortSignal as any).any !== "function") { (AbortSignal as any).any = function (signals: AbortSignal[]): AbortSignal { const controller = new AbortController(); + const onAbort = () => { + const firstAborted = signals.find(s => s.aborted); + controller.abort(firstAborted?.reason); + cleanup(); + }; + const cleanup = () => { + for (const signal of signals) { + signal.removeEventListener("abort", onAbort); + } + }; for (const signal of signals) { if (signal.aborted) { controller.abort(signal.reason); return controller.signal; } - signal.addEventListener( - "abort", - () => { - controller.abort(signal.reason); - }, - { once: true } - ); + signal.addEventListener("abort", onAbort, { once: true }); } + controller.signal.addEventListener("abort", cleanup, { once: true }); return controller.signal; }; } +/** + * Simple combinator for multiple AbortSignals for environments where AbortSignal.any is missing. + */ +function mergeAbortSignals(...signals: (AbortSignal | undefined)[]): AbortSignal { + const activeSignals = signals.filter((s): s is AbortSignal => s !== undefined); + if (activeSignals.length === 0) return new AbortController().signal; + if (activeSignals.length === 1) return activeSignals[0] as AbortSignal; + + if (typeof (AbortSignal as any).any === 'function') { + return (AbortSignal as any).any(activeSignals); + } + + const controller = new AbortController(); + const onAbort = () => { + const firstAborted = activeSignals.find(s => s.aborted); + controller.abort(firstAborted?.reason); + cleanup(); + }; + const cleanup = () => { + for (const signal of activeSignals) { + signal.removeEventListener("abort", onAbort); + } + }; + for (const signal of activeSignals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + controller.signal.addEventListener("abort", cleanup, { once: true }); + return controller.signal; +} + // Module-level toast debounce to persist across requests (fixes toast spam) const rateLimitToastCooldowns = new Map(); const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000; @@ -2063,10 +2102,18 @@ export const createAntigravityPlugin = (providerId: string) => async ( ? Math.min(timeoutMs * 3, 1800000) : timeoutMs; - const timeoutSignal = AbortSignal.timeout(effectiveTimeoutMs); - const combinedSignal = abortSignal - ? (AbortSignal as any).any([abortSignal, timeoutSignal]) - : timeoutSignal; + // Safely create timeout signal with fallback for older Node.js versions + let timeoutSignal: AbortSignal; + if (typeof AbortSignal.timeout === 'function') { + timeoutSignal = AbortSignal.timeout(effectiveTimeoutMs); + } else { + const controller = new AbortController(); + setTimeout(() => controller.abort(new Error('Timeout')), effectiveTimeoutMs); + timeoutSignal = controller.signal; + } + + // Safely create combined signal with polyfill/fallback + const combinedSignal = mergeAbortSignals(abortSignal, timeoutSignal); const response = await fetch(prepared.request, { ...prepared.init,