Files
oh-my-pi/packages/ai/test/google-gemini-cli-429.test.ts
can1357 b4238b10d3 fix: resolved auth-gateway handling of 429 usage-limit responses
- Classified usage-limit gateway responses as `429 rate_limit_error` in auth handling paths.
- Aligned auth-gateway and pi-native key retrieval with derived `sessionId` for `getApiKey` lookups.
- Handled usage-limit auth failures by rotating credentials with retry hints and returning undefined when none available.
- Replaced stream auth checks with retryable-upstream logic for 401 and usage-limit errors before content.
- Expanded `extractRetryHint` parsing for `~`, `sec`, `ms`, and minute/hour units.
- Added coverage for classifyGatewayError, retry-hint parsing variants, and stream-auth retry edge cases.
2026-05-28 02:56:54 +02:00

112 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "bun:test";
import { extractRetryHint } from "@oh-my-pi/pi-utils";
// The fail-fast regex used inside the provider to distinguish "known quota errors" (throw immediately)
// from "ambiguous 429s" (retry up to RATE_LIMIT_BUDGET_MS).
// Option A (minimal): only hard quota limits fail-fast; transient rate-limit messages fall through to retry.
const FAIL_FAST_RE = /quota|exhausted/i;
const shouldFailFast = (errorText: string) => FAIL_FAST_RE.test(errorText);
describe("google-gemini-cli 429 fail-fast detection", () => {
it("fails fast on 'Quota exceeded' messages", () => {
expect(shouldFailFast("Quota exceeded for project")).toBe(true);
});
it("fails fast on 'exhausted' messages", () => {
expect(shouldFailFast("Resource has been exhausted")).toBe(true);
});
it("does not fail fast on ambiguous 429 ('Please retry in 5s')", () => {
expect(shouldFailFast("Please retry in 5s")).toBe(false);
});
it("does not fail fast on generic rate-limit text", () => {
expect(shouldFailFast("Rate limit exceeded, please slow down")).toBe(false);
});
it("matches case-insensitively", () => {
expect(shouldFailFast("QUOTA EXCEEDED")).toBe(true);
expect(shouldFailFast("Resource Has Been Exhausted")).toBe(true);
});
it("does not fail fast on empty error", () => {
expect(shouldFailFast("")).toBe(false);
});
});
describe("extractRetryHint – header parsing", () => {
it("reads retry-after header as seconds", () => {
const headers = new Headers({ "retry-after": "5" });
expect(extractRetryHint(headers)).toBe(5_000);
});
it("reads x-ratelimit-reset-after header as seconds", () => {
const headers = new Headers({ "x-ratelimit-reset-after": "30" });
expect(extractRetryHint(headers)).toBe(30_000);
});
it("prefers retry-after over x-ratelimit-reset-after when both are present", () => {
const headers = new Headers({ "retry-after": "5", "x-ratelimit-reset-after": "30" });
expect(extractRetryHint(headers)).toBe(5_000);
});
});
describe("extractRetryHint – body text parsing", () => {
it("parses 'retryDelay' JSON field in seconds", () => {
expect(extractRetryHint(undefined, '"retryDelay": "3s"')).toBe(3_000);
});
it("parses 'retryDelay' JSON field in milliseconds", () => {
expect(extractRetryHint(undefined, '"retryDelay": "500ms"')).toBe(500);
});
it("parses 'Please retry in Xs' pattern", () => {
expect(extractRetryHint(undefined, "Please retry in 5s")).toBe(5_000);
});
it("parses 'quota will reset after Xs' simple duration", () => {
expect(extractRetryHint(undefined, "Your quota will reset after 39s")).toBe(39_000);
});
it("parses compound duration 'reset after 1h30m10s'", () => {
expect(extractRetryHint(undefined, "Your quota will reset after 1h30m10s")).toBe(5_410_000);
});
it("parses Codex-style 'try again in Xms'", () => {
expect(extractRetryHint(undefined, "try again in 250ms")).toBe(250);
});
it("parses Codex-style 'try again in Xs'", () => {
expect(extractRetryHint(undefined, "try again in 12s")).toBe(12_000);
});
it("parses Codex 'Try again in ~X min.' (usage_limit_reached friendly text)", () => {
// Verbatim shape Codex's parseCodexError builds when usage_limit_reached
// arrives with a `resets_at` minutes-out reset time. Used to fall
// through to undefined → the gateway and TUI both had no retry-after
// signal to honour, so they defaulted to QUOTA_EXHAUSTED's 30-min
// blanket and rotated immediately even when the actual reset window
// was much longer.
expect(extractRetryHint(undefined, "Try again in ~158 min.")).toBe(158 * 60_000);
});
it("parses 'try again in X min' / 'X minutes' without the tilde", () => {
expect(extractRetryHint(undefined, "try again in 5 min")).toBe(5 * 60_000);
expect(extractRetryHint(undefined, "try again in 90 minutes")).toBe(90 * 60_000);
});
it("parses 'try again in X h' / 'X hour' / 'X hours'", () => {
expect(extractRetryHint(undefined, "try again in 2 h")).toBe(2 * 60 * 60_000);
expect(extractRetryHint(undefined, "try again in 1 hour")).toBe(60 * 60_000);
expect(extractRetryHint(undefined, "try again in 3 hours")).toBe(3 * 60 * 60_000);
});
it("returns undefined when body contains no recognised delay pattern", () => {
expect(extractRetryHint(undefined, "Quota exceeded, please try again later")).toBeUndefined();
});
it("returns undefined for empty error string and no headers", () => {
expect(extractRetryHint(undefined, "")).toBeUndefined();
});
});