179 lines
5.1 KiB
TypeScript
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 };
|