Repository navigation
fix(antigravity): skip credits retry on full_quota_exhausted, persist 24h cooldown to DB (#3707) #3754
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
Merged
Merged
fix(antigravity): skip credits retry on full_quota_exhausted, persist 24h cooldown to DB (#3707) #3754
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ import { | |
| handleCreditsFailure, | ||
| } from "../services/antigravityCredits.ts"; | ||
| import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; | ||
| import { setConnectionRateLimitUntil } from "@/lib/db/providers"; | ||
| import { getMitmAlias } from "@/lib/db/models"; | ||
| import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; | ||
| import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; | ||
|
|
@@ -328,6 +329,19 @@ function markCreditsExhausted(accountId: string): void { | |
| creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS); | ||
| } | ||
|
|
||
| /** | ||
| * Persist a quota-exhausted cooldown to the DB for `connectionId` so that | ||
| * cross-request and post-restart routing skips this connection until the | ||
| * cooldown expires. Exported for unit testing. @internal | ||
| */ | ||
| export function markConnectionQuotaExhausted(connectionId: string, retryAfterMs: number): void { | ||
| try { | ||
| setConnectionRateLimitUntil(connectionId, Date.now() + retryAfterMs); | ||
| } catch { | ||
| // DB write failure must never crash the request path | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Accumulate one Antigravity SSE `data:` payload into `collected`. Exported for unit | ||
| * tests (the markdown / candidate-parts extraction branches). @internal | ||
|
|
@@ -1183,10 +1197,21 @@ export class AntigravityExecutor extends BaseExecutor { | |
| const effectiveRetryHintMs = retryMs ?? parsedRetryMs ?? null; | ||
| const category = classify429(errorMessage); | ||
|
|
||
| // 3. For quota_exhausted, attempt Google One AI credits retry FIRST! | ||
| // Skip if credits were already injected on the first call | ||
| // (creditsMode === "always") — no point re-running with the | ||
| // same body. Record the failure so the 5h breaker kicks in. | ||
| // 3. Decide final retry time BEFORE the credits retry so that | ||
| // full_quota_exhausted can skip the credits attempt entirely | ||
| // (avoids ~41s hold on an already-exhausted account) and | ||
| // persist the cooldown to DB for post-restart routing. | ||
| const decision: Decision = decide429(category, parsedRetryMs); | ||
| retryMs = decision.retryAfterMs; | ||
| log?.debug?.( | ||
| "AG_429", | ||
| `Category: ${category}, Decision: ${decision.kind} — ${decision.reason}` | ||
| ); | ||
|
|
||
| if (decision.kind === "full_quota_exhausted" && retryMs) { | ||
| markConnectionQuotaExhausted(accountId, retryMs); | ||
| } | ||
|
|
||
| const creditsAlreadyInjected = | ||
| (transformedBody as { enabledCreditTypes?: unknown }).enabledCreditTypes != null; | ||
|
|
||
|
|
@@ -1198,6 +1223,7 @@ export class AntigravityExecutor extends BaseExecutor { | |
|
|
||
| if ( | ||
| category === "quota_exhausted" && | ||
| decision.kind !== "full_quota_exhausted" && | ||
| !creditsAlreadyInjected && | ||
| shouldRetryWithCredits(credentials?.accessToken || "", creditsMode !== "off") | ||
| ) { | ||
|
Comment on lines
1224
to
1229
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. |
||
|
|
@@ -1269,13 +1295,6 @@ export class AntigravityExecutor extends BaseExecutor { | |
| } | ||
| } | ||
|
|
||
| // 4. Decide final retry time (apply 4-tier engine) | ||
| const decision: Decision = decide429(category, parsedRetryMs); | ||
| retryMs = decision.retryAfterMs; | ||
| log?.debug?.( | ||
| "AG_429", | ||
| `Category: ${category}, Decision: ${decision.kind} — ${decision.reason}` | ||
| ); | ||
| } catch (e) { | ||
| // Ignore parse errors, will fall back to exponential backoff | ||
| } | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| /** | ||
| * TDD regression tests for #3707: | ||
| * 1. `decide429("quota_exhausted")` → `full_quota_exhausted` verdict (engine contract) | ||
| * 2. `markConnectionQuotaExhausted` persists the 24h cooldown in the DB so that | ||
| * cross-request and post-restart routing skips exhausted connections. | ||
| * | ||
| * Bug: before the fix the executor never called `setConnectionRateLimitUntil`, | ||
| * so `isConnectionRateLimited` always returned false for AG connections that | ||
| * had their daily quota exhausted — learned state was lost on restart. | ||
| */ | ||
|
|
||
| import { test } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ag-quota-")); | ||
| process.env.DATA_DIR = TEST_DATA_DIR; | ||
|
|
||
| const core = await import("../../src/lib/db/core.ts"); | ||
| const providersDb = await import("../../src/lib/db/providers.ts"); | ||
|
|
||
| import { | ||
| classify429, | ||
| decide429, | ||
| FULL_QUOTA_COOLDOWN_MS, | ||
| } from "../../open-sse/services/antigravity429Engine.ts"; | ||
| import { markConnectionQuotaExhausted } from "../../open-sse/executors/antigravity.ts"; | ||
|
|
||
| test.after(() => { | ||
| core.resetDbInstance(); | ||
| fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| // ── Engine contract (regression guard) ─────────────────────────────────────── | ||
|
|
||
| test("decide429: quota_exhausted category → full_quota_exhausted kind with 24h cooldown", () => { | ||
| const decision = decide429("quota_exhausted", null); | ||
| assert.equal(decision.kind, "full_quota_exhausted"); | ||
| assert.equal(decision.retryAfterMs, FULL_QUOTA_COOLDOWN_MS); | ||
| assert.equal(FULL_QUOTA_COOLDOWN_MS, 24 * 60 * 60 * 1000, "cooldown must be 24h"); | ||
| }); | ||
|
|
||
| test("decide429: quota_exhausted with explicit retryAfterMs preserves the provided value", () => { | ||
| const twoDaysMs = 2 * 24 * 60 * 60 * 1000; | ||
| const decision = decide429("quota_exhausted", twoDaysMs); | ||
| assert.equal(decision.kind, "full_quota_exhausted"); | ||
| assert.equal(decision.retryAfterMs, twoDaysMs); | ||
| }); | ||
|
|
||
| test("classify429: AG 'Individual quota reached' message → quota_exhausted", () => { | ||
| const msg = | ||
| "Individual quota reached. Contact your administrator to enable overages. Resets in 14h22m."; | ||
| assert.equal(classify429(msg), "quota_exhausted"); | ||
| }); | ||
|
|
||
| // ── DB persistence (the missing wire — Bug #2) ─────────────────────────────── | ||
|
|
||
| test("markConnectionQuotaExhausted persists 24h cooldown; isConnectionRateLimited returns true", async () => { | ||
| const conn = await providersDb.createProviderConnection({ | ||
| provider: "antigravity", | ||
| authType: "oauth", | ||
| name: "AG Test Quota", | ||
| }); | ||
| const connId = (conn as any).id; | ||
|
|
||
| assert.equal( | ||
| providersDb.isConnectionRateLimited(connId), | ||
| false, | ||
| "should start as not rate-limited" | ||
| ); | ||
|
|
||
| markConnectionQuotaExhausted(connId, FULL_QUOTA_COOLDOWN_MS); | ||
|
|
||
| assert.equal( | ||
| providersDb.isConnectionRateLimited(connId), | ||
| true, | ||
| "should be rate-limited after marking quota exhausted" | ||
| ); | ||
| }); | ||
|
|
||
| test("markConnectionQuotaExhausted: expired cooldown does not block the connection", async () => { | ||
| const conn = await providersDb.createProviderConnection({ | ||
| provider: "antigravity", | ||
| authType: "oauth", | ||
| name: "AG Test Expired", | ||
| }); | ||
| const connId = (conn as any).id; | ||
|
|
||
| // Set cooldown in the past — simulates expired cooldown | ||
| providersDb.setConnectionRateLimitUntil(connId, Date.now() - 1); | ||
| assert.equal( | ||
| providersDb.isConnectionRateLimited(connId), | ||
| false, | ||
| "expired cooldown should not block" | ||
| ); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
In this PR,
decide429()is called before the credits retry check. However,decide429(category, ...)always returnsdecision.kind === "full_quota_exhausted"whencategoryis"quota_exhausted"(as verified by the unit testdecide429: quota_exhausted category → full_quota_exhausted kind).Because of this:
decision.kind !== "full_quota_exhausted"will always evaluate tofalsewhencategory === "quota_exhausted".ifblock for retrying with credits (lines 1224-1229) is now dead code and will never be executed.quota_exhaustederror, the connection is immediately marked as fully exhausted in the DB for 24 hours, completely bypassing the Google One AI credits retry mechanism.Solution
We should only mark the connection as
full_quota_exhaustedand skip the credits retry if we cannot retry with credits (i.e., credits are already injected, or credits mode is off/not supported). Let's definecanRetryWithCreditsfirst, and then use it to determine whether we should mark the connection as exhausted.