From 00860f32789426008f372bb21ddc486f7319c94d Mon Sep 17 00:00:00 2001 From: Dmitry Kuznetsov Date: Thu, 17 Sep 2026 01:59:43 +0300 Subject: [PATCH] fix(antigravity): rotate image accounts on explicit quota exhaustion (#9908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged after batch validation on a combined worktree cut from `release/v3.8.51` with #9944 and #7138. **Evidence** - Focused tests: 36/36 pass on the combined tree, including your 4 classification-boundary cases in `tests/unit/antigravity-image-credential-retry.test.ts` (Antigravity quota-exhausted `429` rotates; generic `RESOURCE_EXHAUSTED`, ordinary image rate-limit and non-Antigravity `429` stay terminal). - Gates on the combined tree: `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check-changelog-integrity` PASS. - The red `check-file-size` reproduces byte-identical on the pure `release/v3.8.51` tip — inherited base-red, not from this PR. The red CI run on this PR dates from 2026-09-15 against an older base. **Related dispositions** - #8053 is being closed in your favour: it chased the same account-rotation goal across 3 files plus a new `routingInstrumentation.ts`, while its `AbortSignal` half was already superseded on the tip by independent work. This PR does the same job in 31 lines of production code by reusing the existing `classify429` engine. Thanks, @Ardem2025 — the deliberate narrowness here is the reason this merged and the bigger version didn't. Gating rotation on `provider === "antigravity" && status === 429 && classify429() === "quota_exhausted"` keeps non-idempotent image generation from being retried on ordinary rate limits, and you proved each negative case rather than just the happy path. --- ...ntigravity-image-quota-account-rotation.md | 1 + src/sse/services/imageCredentialRetry.ts | 31 +++++++++++- ...antigravity-image-credential-retry.test.ts | 48 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/9908-antigravity-image-quota-account-rotation.md create mode 100644 tests/unit/antigravity-image-credential-retry.test.ts diff --git a/changelog.d/fixes/9908-antigravity-image-quota-account-rotation.md b/changelog.d/fixes/9908-antigravity-image-quota-account-rotation.md new file mode 100644 index 0000000000..6f6f802c23 --- /dev/null +++ b/changelog.d/fixes/9908-antigravity-image-quota-account-rotation.md @@ -0,0 +1 @@ +- Fix Antigravity image generation not rotating to another account when the upstream returns an explicit quota-exhausted 429, so a second configured account with available quota is no longer stuck behind the first account's terminal quota error. diff --git a/src/sse/services/imageCredentialRetry.ts b/src/sse/services/imageCredentialRetry.ts index 6f804bbf1f..beda34ac63 100644 --- a/src/sse/services/imageCredentialRetry.ts +++ b/src/sse/services/imageCredentialRetry.ts @@ -1,3 +1,4 @@ +import { classify429 } from "@omniroute/open-sse/services/antigravity429Engine.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; import { getProviderCredentialsWithQuotaPreflight } from "./auth"; @@ -50,6 +51,29 @@ function isCredentialSentinel(credentials: any): boolean { return Boolean(credentials?.allRateLimited || credentials?.allExpired); } +/** + * Image generation is non-idempotent, so account rotation stays deliberately + * narrower than chat failover. Antigravity's explicit exhausted-quota signal + * is safe to retry on another account; an ordinary 429 is not evidence that a + * different account helps and must not cause account rotation. + */ +export function isAntigravityImageQuotaExhausted( + provider: string, + result: ImageGenerationResult +): boolean { + if (provider !== "antigravity" || Number(result.status) !== 429) return false; + + let errorText = ""; + try { + errorText = + typeof result.error === "string" ? result.error : JSON.stringify(result.error ?? ""); + } catch { + return false; + } + + return classify429(errorText) === "quota_exhausted"; +} + async function defaultSelectNextCredentials( provider: string, requestedModel: string | null, @@ -110,8 +134,11 @@ export async function executeImageWithCredentialFallback({ lastCredentials = currentCredentials; lastResult = await execute(currentCredentials); - const isAuthFailure = Number(lastResult.status) === 401 || lastResult.retryable === true; - if (lastResult.success || !isAuthFailure || !connectionId) { + const shouldTryAnotherAccount = + Number(lastResult.status) === 401 || + lastResult.retryable === true || + isAntigravityImageQuotaExhausted(provider, lastResult); + if (lastResult.success || !shouldTryAnotherAccount || !connectionId) { return { credentials: lastCredentials, result: lastResult }; } diff --git a/tests/unit/antigravity-image-credential-retry.test.ts b/tests/unit/antigravity-image-credential-retry.test.ts new file mode 100644 index 0000000000..bd42bc268e --- /dev/null +++ b/tests/unit/antigravity-image-credential-retry.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isAntigravityImageQuotaExhausted } from "../../src/sse/services/imageCredentialRetry.ts"; + +test("Antigravity image quota-exhausted 429 is the only 429 eligible for account rotation", () => { + assert.equal( + isAntigravityImageQuotaExhausted("antigravity", { + success: false, + status: 429, + error: { error: { message: "Individual quota reached" } }, + }), + true + ); +}); + +test("ordinary Hermes-shaped 429 does not create a rotation/cooldown signal", () => { + assert.equal( + isAntigravityImageQuotaExhausted("antigravity", { + success: false, + status: 429, + error: { error: { message: "RESOURCE_EXHAUSTED: malformed system payload" } }, + }), + false + ); +}); + +test("ordinary image rate-limit 429 does not create a rotation/cooldown signal", () => { + assert.equal( + isAntigravityImageQuotaExhausted("antigravity", { + success: false, + status: 429, + error: { error: { message: "too many requests; retry later" } }, + }), + false + ); +}); + +test("non-Antigravity 429 is not eligible for Antigravity account rotation", () => { + assert.equal( + isAntigravityImageQuotaExhausted("openai", { + success: false, + status: 429, + error: { error: { message: "Individual quota reached" } }, + }), + false + ); +});