fix(antigravity): 429 hang on credit exhaustion and precise reset time lockout (Cleaned) (#5846)

Integrated into release/v3.8.43
This commit is contained in:
Chewji
2026-07-02 08:01:26 +07:00
committed by GitHub
parent 057dc3abf3
commit e5e47eb61c
8 changed files with 134 additions and 11 deletions

View File

@@ -1388,7 +1388,14 @@ export class AntigravityExecutor extends BaseExecutor {
try {
const errorBody = await response.clone().text();
const errorJson = JSON.parse(errorBody);
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
let errorMessage = errorJson?.error?.message || errorJson?.message || "";
if (errorJson?.error?.details && Array.isArray(errorJson.error.details)) {
for (const detail of errorJson.error.details) {
if (detail?.reason) {
errorMessage += ` ${detail.reason}`;
}
}
}
// 1. Try to parse explicit retry time from message
const parsedRetryMs = this.parseRetryFromErrorMessage(errorMessage);

View File

@@ -1296,6 +1296,7 @@ export function checkFallbackError(
* provider itself). Callers should apply connection cooldown only — do NOT record a provider
* circuit-breaker failure when this flag is set. */
skipProviderBreaker?: boolean;
quotaResetHintMs?: number;
} {
// G-02: detect embedded service supervisor failures (X-Omni-Fallback-Hint: connection_cooldown).
// These are NOT upstream AI provider failures — they are local supervisor state changes.
@@ -1478,11 +1479,13 @@ export function checkFallbackError(
// profile.useUpstreamRetryHints.
const hintMs = getUpstreamRetryHintMs();
const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
const bodyHint = parseRetryFromErrorText(errorStr);
return {
shouldFallback: true,
cooldownMs: hintMs ?? SUBSCRIPTION_QUOTA_COOLDOWN_MS,
reason: RateLimitReason.QUOTA_EXHAUSTED,
usedUpstreamRetryHint: Boolean(hintMs),
quotaResetHintMs: bodyHint ?? undefined,
};
}
@@ -1492,7 +1495,11 @@ export function checkFallbackError(
quotaResetHintMs &&
classifyErrorText(errorStr) === RateLimitReason.QUOTA_EXHAUSTED
) {
return buildRetryableFallback(RateLimitReason.QUOTA_EXHAUSTED);
const fallbackResult = buildRetryableFallback(RateLimitReason.QUOTA_EXHAUSTED);
return {
...fallbackResult,
quotaResetHintMs,
};
}
// #2929: A route-restriction 403 (e.g. Fireworks Fire Pass keys returning

View File

@@ -21,10 +21,7 @@
export type Category = "unknown" | "rate_limited" | "quota_exhausted" | "soft_rate_limit";
export type DecisionKind =
| "soft_retry"
| "instant_retry_same_auth"
| "short_cooldown_switch_auth"
| "full_quota_exhausted";
"soft_retry" | "instant_retry_same_auth" | "short_cooldown_switch_auth" | "full_quota_exhausted";
export interface Decision {
kind: DecisionKind;
@@ -53,6 +50,8 @@ const CREDITS_EXHAUSTED_KEYWORDS = [
"minimumcreditamountforusage",
"minimum credit amount for usage",
"minimum credit",
"insufficient_g1_credits_balance",
"g1_credits",
];
const SHORT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes

View File

@@ -52,6 +52,7 @@ const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
// per-minute limits like "request quota reached, retry in 60s".
/individual quota reached/i,
/enable overages/i,
/INSUFFICIENT_G1_CREDITS_BALANCE/i,
];
/**

View File

@@ -2020,7 +2020,9 @@ export async function markAccountUnavailable(
{
...modelLockoutOptions,
exactCooldownMs:
fallbackResult.usedUpstreamRetryHint === true ? fallbackResult.cooldownMs : null,
fallbackResult.usedUpstreamRetryHint === true
? fallbackResult.cooldownMs
: (fallbackResult.quotaResetHintMs ?? null),
maxCooldownMs: mlSettings.maxCooldownMs,
}
);

View File

@@ -55,6 +55,10 @@ test("classify429: AG 'Individual quota reached' message → quota_exhausted", (
assert.equal(classify429(msg), "quota_exhausted");
});
test("classify429: AG G1 Credits Exhausted message → quota_exhausted", () => {
assert.equal(classify429("insufficient_g1_credits_balance"), "quota_exhausted");
});
test("classify429: standard Gemini rate limit 'resource has been exhausted' -> rate_limited or unknown, not quota_exhausted", () => {
const msg =
"RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached).";

View File

@@ -0,0 +1,88 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { classify429 as classify429AG } from "../../open-sse/services/antigravity429Engine.ts";
import { classify429 as classify429Shared } from "../../src/shared/utils/classify429.ts";
import {
parseRetryFromErrorText,
checkFallbackError,
} from "../../open-sse/services/accountFallback.ts";
test("TDD S1: classify429 (Antigravity engine) detects INSUFFICIENT_G1_CREDITS_BALANCE", () => {
const msg = JSON.stringify({
error: {
code: 429,
message: "Resource has been exhausted (e.g. check quota).",
status: "RESOURCE_EXHAUSTED",
details: [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
reason: "INSUFFICIENT_G1_CREDITS_BALANCE",
},
],
},
});
const category = classify429AG(msg);
assert.equal(category, "quota_exhausted");
});
test("TDD S1: classify429 (Shared utility) detects INSUFFICIENT_G1_CREDITS_BALANCE", () => {
const body = {
error: {
code: 429,
message: "Resource has been exhausted (e.g. check quota).",
status: "RESOURCE_EXHAUSTED",
details: [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
reason: "INSUFFICIENT_G1_CREDITS_BALANCE",
},
],
},
};
const kind = classify429Shared({ status: 429, body });
assert.equal(kind, "quota_exhausted");
});
test("TDD S2: Regression: standard Gemini rate limit 'queries per minute limit was reached' -> rate_limit (shared) and rate_limited (AG)", () => {
const msg =
"RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached).";
assert.notEqual(classify429AG(msg), "quota_exhausted");
assert.equal(classify429Shared({ status: 429, body: msg }), "rate_limit");
});
test("TDD S3: parseRetryFromErrorText parses resets in 5h and resets in 164h27m24s", () => {
// Antigravity returns: "Individual quota reached. Contact your administrator to enable overages. Resets in 5h."
const msg5h =
"Individual quota reached. Contact your administrator to enable overages. Resets in 5h.";
const msgWeekly =
"Individual quota reached. Contact your administrator to enable overages. Resets in 164h27m24s.";
const val5h = parseRetryFromErrorText(msg5h);
assert.equal(val5h, 5 * 3600 * 1000);
const valWeekly = parseRetryFromErrorText(msgWeekly);
assert.equal(valWeekly, 164 * 3600 * 1000 + 27 * 60 * 1000 + 24 * 1000);
});
test("TDD S3: checkFallbackError extracts retry hint for oauth providers even if useUpstreamRetryHints is false", () => {
const errorText =
"Individual quota reached. Contact your administrator to enable overages. Resets in 5h.";
const res = checkFallbackError(
429,
errorText,
0,
"gemini-3.5-flash",
"antigravity", // which uses oauth provider profile (useUpstreamRetryHints: false)
null
);
assert.equal(res.shouldFallback, true);
assert.equal(res.usedUpstreamRetryHint, false);
// Connection cooldown should be the default/scaled backoff cooldown (e.g. ~5000 ms) because useUpstreamRetryHints is false
assert.notEqual(res.cooldownMs, 5 * 3600 * 1000);
// But quotaResetHintMs MUST be the precise parsed reset time (5h = 18,000,000 ms)
assert.equal(res.quotaResetHintMs, 5 * 3600 * 1000);
});

View File

@@ -41,10 +41,25 @@ test("classify429: Antigravity 'Individual quota reached' body returns 'quota_ex
"Resets in 164h27m24s.";
assert.equal(looksLikeQuotaExhausted(body), true);
assert.equal(classify429({ status: 429, body }), "quota_exhausted");
assert.equal(
classify429({ status: 429, body: { error: { message: body } } }),
"quota_exhausted"
);
assert.equal(classify429({ status: 429, body: { error: { message: body } } }), "quota_exhausted");
});
test("classify429: Antigravity INSUFFICIENT_G1_CREDITS_BALANCE body returns 'quota_exhausted'", () => {
const body = {
error: {
code: 429,
message: "Resource has been exhausted (e.g. check quota).",
status: "RESOURCE_EXHAUSTED",
details: [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
reason: "INSUFFICIENT_G1_CREDITS_BALANCE",
},
],
},
};
assert.equal(looksLikeQuotaExhausted(body), true);
assert.equal(classify429({ status: 429, body }), "quota_exhausted");
});
test("classify429: Antigravity quota patterns do not over-match plain rate limits", () => {