diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 0efddc1762..1ade866731 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -33,6 +33,7 @@ import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/provid import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts"; +import { parseRetryHintFromJsonBody } from "./retryAfterJson.ts"; export type ProviderProfile = { baseCooldownMs: number; @@ -1035,22 +1036,15 @@ function parseDelayString(value: unknown): number | null { return Number.isNaN(num) ? null : num * 1000; } -/** - * T07: Parse retry time from error text body with combined "XhYmZs" format. - * Examples: "Your quota will reset after 2h30m14s", "reset after 45m", "reset after 30s" - * Returns milliseconds or null if not parseable. - * - * @param {string} errorText - Error message text from response body - * @returns {number|null} Retry duration in milliseconds - */ +// T07: parse retry time from error text body with combined "XhYmZs" format. export function parseRetryFromErrorText(errorText: unknown): number | null { if (!errorText || typeof errorText !== "string") return null; const msg: string = String(errorText); - // Issue #2321: Anthropic OAuth occasionally embeds an absolute ISO 8601 - // timestamp instead of a relative duration (e.g. "Try again at - // 2026-05-17T10:00:00Z" or "Please wait until 2026-05-17T10:00:00.000Z"). - // Convert to a future-duration in milliseconds if it parses. + const bodyHintMs = parseRetryHintFromJsonBody(msg, MAX_PROVIDER_COOLDOWN_MS); + if (bodyHintMs !== null) return bodyHintMs; + + // Issue #2321: parse embedded absolute ISO retry timestamps. const isoMatch = /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i.exec( msg diff --git a/open-sse/services/retryAfterJson.ts b/open-sse/services/retryAfterJson.ts new file mode 100644 index 0000000000..1426fc3447 --- /dev/null +++ b/open-sse/services/retryAfterJson.ts @@ -0,0 +1,44 @@ +type JsonRecord = Record; + +function objectRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function positiveCappedMs(value: unknown, maxMs: number): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.min(value, maxMs) + : null; +} + +function futureTimestampMs(value: unknown, maxMs: number): number | null { + if (typeof value !== "string") return null; + const parsedTs = Date.parse(value); + if (!Number.isFinite(parsedTs)) return null; + const waitMs = parsedTs - Date.now(); + return waitMs > 0 ? Math.min(waitMs, maxMs) : null; +} + +/** + * Parse Retry-After hints from a 429 JSON response body. Providers use both + * top-level and nested `error` fields for ISO timestamps and millisecond values. + */ +export function parseRetryHintFromJsonBody(body: string, maxMs: number): number | null { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return null; + } + + const root = objectRecord(parsed); + if (!Object.keys(root).length) return null; + const errorObj = objectRecord(root.error); + + const isoHint = futureTimestampMs(errorObj.retryAfter ?? root.retryAfter, maxMs); + if (isoHint !== null) return isoHint; + + return positiveCappedMs( + errorObj.retry_after_ms ?? root.retry_after_ms ?? errorObj.retryAfterMs ?? root.retryAfterMs, + maxMs + ); +} diff --git a/tests/unit/account-fallback-retry-after-json.test.ts b/tests/unit/account-fallback-retry-after-json.test.ts new file mode 100644 index 0000000000..515dd46351 --- /dev/null +++ b/tests/unit/account-fallback-retry-after-json.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { parseRetryFromErrorText } = await import("../../open-sse/services/accountFallback.ts"); + +test("parseRetryFromErrorText reads nested ISO retryAfter from a 429 JSON body", () => { + const futureIso = new Date(Date.now() + 120_000).toISOString(); + const waitMs = parseRetryFromErrorText(JSON.stringify({ error: { retryAfter: futureIso } })); + assert.ok(waitMs !== null, "expected a parsed wait time, got null"); + assert.ok(Math.abs((waitMs as number) - 120_000) <= 2_000, `expected ~120000ms, got ${waitMs}`); +}); + +test("parseRetryFromErrorText reads top-level retryAfter when nested field is absent", () => { + const futureIso = new Date(Date.now() + 60_000).toISOString(); + const waitMs = parseRetryFromErrorText(JSON.stringify({ retryAfter: futureIso })); + assert.ok(waitMs !== null, "expected a parsed wait time, got null"); + assert.ok(Math.abs((waitMs as number) - 60_000) <= 2_000, `expected ~60000ms, got ${waitMs}`); +}); + +test("parseRetryFromErrorText reads millisecond retry hints from 429 JSON bodies", () => { + assert.equal(parseRetryFromErrorText(JSON.stringify({ retry_after_ms: 45_000 })), 45_000); + assert.equal( + parseRetryFromErrorText(JSON.stringify({ error: { retry_after_ms: 12_000 } })), + 12_000 + ); + assert.equal(parseRetryFromErrorText(JSON.stringify({ retryAfterMs: 8_000 })), 8_000); +}); + +test("parseRetryFromErrorText ignores past ISO retryAfter values in JSON bodies", () => { + const pastIso = new Date(Date.now() - 60_000).toISOString(); + assert.equal(parseRetryFromErrorText(JSON.stringify({ error: { retryAfter: pastIso } })), null); +});