Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### 🐛 Fixed

- fix(antigravity): skip Google One AI credits retry on `full_quota_exhausted` verdict — antigravity executor now calls `decide429()` before attempting the credits retry so that a quota-exhausted account (24h cooldown) bypasses the extra upstream HTTP call instead of hanging for up to ~41s. Also persists the cooldown in the DB via `setConnectionRateLimitUntil` so post-restart routing skips exhausted connections without re-learning the hard way. Bonus: `antigravity429Engine` now recognises the real Antigravity "Individual quota reached. Contact your administrator to enable overages." error message as `quota_exhausted`. ([#3707](https://github.com/diegosouzapw/OmniRoute/issues/3707) — thanks @andrea-kingautomation)

- fix(cli): `ServerSupervisor.handleExit` now coerces the exit code to a number before calling `process.exit()` — Node.js v24 throws `TypeError [ERR_INVALID_ARG_TYPE]` when `process.exit()` receives a string (e.g. `'ENOENT'` from a spawn `error` event's `err.code`). The `error` callback also now passes `-1` instead of the raw `err.code`, which is an OS error string rather than a meaningful exit code. ([#3748](https://github.com/diegosouzapw/OmniRoute/issues/3748))

---
Expand Down
41 changes: 30 additions & 11 deletions open-sse/executors/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Comment on lines +1200 to 1216

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ Critical Logic Bug: Google One AI Credits Retry is completely disabled

In this PR, decide429() is called before the credits retry check. However, decide429(category, ...) always returns decision.kind === "full_quota_exhausted" when category is "quota_exhausted" (as verified by the unit test decide429: quota_exhausted category → full_quota_exhausted kind).

Because of this:

  1. decision.kind !== "full_quota_exhausted" will always evaluate to false when category === "quota_exhausted".
  2. As a result, the if block for retrying with credits (lines 1224-1229) is now dead code and will never be executed.
  3. On the very first quota_exhausted error, 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_exhausted and 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 define canRetryWithCredits first, and then use it to determine whether we should mark the connection as exhausted.

              const creditsAlreadyInjected =
                (transformedBody as { enabledCreditTypes?: unknown }).enabledCreditTypes != null;

              const canRetryWithCredits =
                category === "quota_exhausted" &&
                !creditsAlreadyInjected &&
                shouldRetryWithCredits(credentials?.accessToken || "", creditsMode !== "off");

              // 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}`
              );

              const isFullQuotaExhausted =
                decision.kind === "full_quota_exhausted" && !canRetryWithCredits;

              if (isFullQuotaExhausted && retryMs) {
                markConnectionQuotaExhausted(accountId, retryMs);
              }


Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Simplify the condition to use the pre-computed canRetryWithCredits flag. This avoids the logical contradiction where decision.kind !== "full_quota_exhausted" was always evaluating to false for quota-exhausted errors.

              if (canRetryWithCredits) {

Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 8 additions & 1 deletion open-sse/services/antigravity429Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ export interface Decision {
reason: string;
}

const QUOTA_EXHAUSTED_KEYWORDS = ["quota_exhausted", "quota exhausted"];
const QUOTA_EXHAUSTED_KEYWORDS = [
"quota_exhausted",
"quota exhausted",
// Antigravity native message: "Individual quota reached. Contact your administrator to enable overages."
"quota reached",
"enable overages",
"individual quota",
];

const CREDITS_EXHAUSTED_KEYWORDS = [
"google_one_ai",
Expand Down
98 changes: 98 additions & 0 deletions tests/unit/antigravity-429-quota-cooldown.test.ts
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"
);
});