mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(resilience): parse Retry-After from 429 JSON body for cooldown (#5974)
Integrated into release/v3.8.44 — parse Retry-After from 429 JSON body for cooldown (incl. #6013 retry-after-json extraction by @KooshaPari).
This commit is contained in:
committed by
GitHub
parent
cd10a6f5f4
commit
85a7be992c
@@ -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
|
||||
|
||||
44
open-sse/services/retryAfterJson.ts
Normal file
44
open-sse/services/retryAfterJson.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
32
tests/unit/account-fallback-retry-after-json.test.ts
Normal file
32
tests/unit/account-fallback-retry-after-json.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user