-
-
Notifications
You must be signed in to change notification settings - Fork 647
fix: improve account rotation with request timeouts and interruption safety #523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
52d9b51
3757ec3
f12df55
b1306a4
203d4db
a4745c1
506698b
2b9b575
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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"; | ||||||||||||||||
|
|
@@ -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<string, number>(); | ||||||||||||||||
| const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000; | ||||||||||||||||
|
|
@@ -90,7 +116,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) { | ||||||||||||||||
|
|
@@ -157,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) }); | ||||||||||||||||
|
|
@@ -1963,6 +1988,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( | |||||||||||||||
| continue; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| let effectiveTimeoutMs = (config.request_timeout_seconds ?? 600) * 1000; | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. declared but immediately overwritten at line 2100, making this initialization unnecessary Prompt To Fix With AIThis is a comment left during a code review.
Path: src/plugin.ts
Line: 2029
Comment:
declared but immediately overwritten at line 2100, making this initialization unnecessary
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||
|
|
||||||||||||||||
| try { | ||||||||||||||||
| const prepared = prepareAntigravityRequest( | ||||||||||||||||
| input, | ||||||||||||||||
|
|
@@ -2022,7 +2049,29 @@ 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 (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); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Create a combined signal for timeout and user abort | ||||||||||||||||
| 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]) | ||||||||||||||||
|
||||||||||||||||
| ? (AbortSignal as any).any([abortSignal, timeoutSignal]) | |
| // 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.any ? abortSignal.any([abortSignal, timeoutSignal]) : mergeAbortSignals(abortSignal, timeoutSignal)) | |
| : timeoutSignal; |
Alternatively, you could add a polyfill at the top of the file:
// Polyfill for older Node.js versions
if (typeof AbortSignal.any === 'undefined') {
AbortSignal.any = function(signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
break;
}
signal.addEventListener('abort', () => {
controller.abort(signal.reason);
}, { once: true });
}
return controller.signal;
};
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: NoeFabris/opencode-antigravity-auth
Length of output: 2461
Polyfill should clean up sibling listeners after first abort.
When any signal aborts, remaining listeners attached to other signals are never detached. The
{ once: true }option only removes listeners after they fire, not before. In fallback runtimes (Node 20.0-20.16), repeated requests with overlapping signals cause listeners to accumulate on those signals, creating a memory leak.Proposed fix
(AbortSignal as any).any = function (signals: AbortSignal[]): AbortSignal { const controller = new AbortController(); + const listeners = new Map<AbortSignal, () => void>(); + const cleanup = () => { + for (const [s, fn] of listeners) s.removeEventListener("abort", fn); + listeners.clear(); + }; for (const signal of signals) { if (signal.aborted) { controller.abort(signal.reason); return controller.signal; } - signal.addEventListener( - "abort", - () => { - controller.abort(signal.reason); - }, - { once: true } - ); + const onAbort = () => { + cleanup(); + controller.abort(signal.reason); + }; + listeners.set(signal, onAbort); + signal.addEventListener("abort", onAbort, { once: true }); } return controller.signal; };📝 Committable suggestion
🤖 Prompt for AI Agents