Files
2026-07-13 13:39:12 +08:00

179 lines
5.1 KiB
TypeScript

/**
* Antigravity 429 classification and retry decision engine.
*
* CLIProxyAPI classifies 429 responses into 4 categories and makes nuanced
* retry decisions for each. OmniRoute previously had only 2 categories
* (free tier vs RPM). This module brings full parity.
*
* Categories:
* - unknown: Generic 429, exponential backoff
* - rate_limited: Per-minute rate limit, short backoff + same auth retry
* - quota_exhausted: Daily/plan quota gone, switch auth or long cooldown
* - soft_rate_limit: Temporary burst limit, instant retry
*
* Decisions:
* - soft_retry: Wait briefly, retry same auth
* - instant_retry_same_auth: Retry immediately on same auth
* - short_cooldown_switch_auth: 5min cooldown, try next account
* - full_quota_exhausted: 24h cooldown, skip this account
*/
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";
export interface Decision {
kind: DecisionKind;
retryAfterMs: number | null;
reason: string;
}
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",
"insufficient credit",
"insufficient credits",
"not enough credit",
"not enough credits",
"credit exhausted",
"credits exhausted",
"credit balance",
"minimumcreditamountforusage",
"minimum credit amount for usage",
"minimum credit",
"insufficient_g1_credits_balance",
"g1_credits",
];
const SHORT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
const INSTANT_RETRY_THRESHOLD_MS = 3 * 1000; // 3 seconds
const FULL_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
export function classify429(errorMessage: string): Category {
const lower = (errorMessage || "").toLowerCase();
// Check for quota exhaustion first (most specific)
for (const kw of QUOTA_EXHAUSTED_KEYWORDS) {
if (lower.includes(kw)) return "quota_exhausted";
}
// Check for credits exhaustion (also quota-related)
for (const kw of CREDITS_EXHAUSTED_KEYWORDS) {
if (lower.includes(kw)) return "quota_exhausted";
}
// Check for RPM/rate limit indicators
if (
lower.includes("per minute") ||
lower.includes("rpm") ||
lower.includes("rate limit") ||
lower.includes("rate_limit") ||
lower.includes("too many requests")
) {
return "rate_limited";
}
// Check for free tier exhaustion
if (
lower.includes("free tier") ||
lower.includes("daily limit") ||
lower.includes("exhausted your capacity")
) {
return "quota_exhausted";
}
// Check for soft/burst limits
if (lower.includes("try again") || lower.includes("temporarily")) {
return "soft_rate_limit";
}
return "unknown";
}
export function decide429(category: Category, retryAfterMs: number | null): Decision {
switch (category) {
case "soft_rate_limit":
return {
kind:
retryAfterMs && retryAfterMs <= INSTANT_RETRY_THRESHOLD_MS
? "instant_retry_same_auth"
: "soft_retry",
retryAfterMs: retryAfterMs ?? 2000,
reason: "Soft rate limit — brief backoff",
};
case "rate_limited":
return {
kind:
retryAfterMs && retryAfterMs <= SHORT_COOLDOWN_MS
? "soft_retry"
: "short_cooldown_switch_auth",
retryAfterMs: retryAfterMs ?? 60_000,
reason: "RPM rate limit — switch auth if cooldown is long",
};
case "quota_exhausted":
return {
kind: "full_quota_exhausted",
retryAfterMs: retryAfterMs ?? FULL_QUOTA_COOLDOWN_MS,
reason: "Quota exhausted — skip this account",
};
default:
return {
kind: "soft_retry",
retryAfterMs: retryAfterMs ?? 5000,
reason: "Unknown 429 — generic backoff",
};
}
}
/**
* Track credits failure state per auth key.
* Auto-disables after repeated failures with 5h cooldown.
*/
const creditsFailureMap = new Map<
string,
{
count: number;
disabledUntil: number;
}
>();
const CREDITS_DISABLE_THRESHOLD = 3;
const CREDITS_COOLDOWN_MS = 5 * 60 * 60 * 1000; // 5 hours
export function recordCreditsFailure(authKey: string): boolean {
const state = creditsFailureMap.get(authKey) ?? { count: 0, disabledUntil: 0 };
state.count++;
if (state.count >= CREDITS_DISABLE_THRESHOLD) {
state.disabledUntil = Date.now() + CREDITS_COOLDOWN_MS;
creditsFailureMap.set(authKey, state);
return true; // disabled
}
creditsFailureMap.set(authKey, state);
return false;
}
export function isCreditsDisabled(authKey: string): boolean {
const state = creditsFailureMap.get(authKey);
if (!state) return false;
if (state.disabledUntil > Date.now()) return true;
// Cooldown expired, reset
creditsFailureMap.delete(authKey);
return false;
}
export { SHORT_COOLDOWN_MS, FULL_QUOTA_COOLDOWN_MS };