From ee31f4ebe553383bd0045be6597cb93d95463c19 Mon Sep 17 00:00:00 2001 From: unolife <38601861+unolife@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:21:49 +0900 Subject: [PATCH] Read a relative Retry-After out of the message, not only an absolute deadline `parseRateLimitRetryAt` understands one phrasing: the gateway's "rate limited until ". A plain 429 does not use it -- it carries a `Retry-After`, which reaches an extension only as text, rendered as a relative wait. The existing suite pins that gap in place: `"429 Too Many Requests"` is asserted to yield undefined. When it does, `rateLimitWaitMs` has nothing to report, so the notice cannot say when the limit lifts and `prepareRetry` falls back to exponential backoff against a limit that had just said how long it lasts. A relative form is now read as a fallback: "retry after 30 seconds", "try again in 45s", "resets in 1 hour", "retry after 1500ms". The absolute deadline is still preferred when a message states both -- it needs no arithmetic and no assumption about when the message was produced. A zero wait, an unrecognised unit, and a duration with no retry wording all stay undefined, so an unrelated "the request took 30 seconds" is not mistaken for a deadline. Verified: `vitest run --dir src` -- 10949 passed, 17 skipped. The new cases fail on the unmodified parser (6 failures) and pass with it. --- src/llm-gateway-error.test.ts | 29 ++++++++++++++++++++ src/llm-gateway-error.ts | 50 ++++++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/llm-gateway-error.test.ts b/src/llm-gateway-error.test.ts index 43f303b9d..57e3980d7 100644 --- a/src/llm-gateway-error.test.ts +++ b/src/llm-gateway-error.test.ts @@ -355,6 +355,35 @@ describe("parseRateLimitRetryAt", () => { ])("returns undefined for $name", ({ message }) => { expect(parseRateLimitRetryAt(message, NOW)).toBeUndefined() }) + + // A plain 429 does not use the gateway's "rate limited until" wording. It + // carries a Retry-After, which reaches this extension only as text, rendered + // as a relative wait -- so without this the deadline is lost and the retry + // backs off blindly against a limit that just said how long it lasts. + it.each([ + { name: "seconds", message: "429 Too Many Requests, retry after 30 seconds", ms: 30_000 }, + { name: "abbreviated seconds", message: "Too many requests. Try again in 45s.", ms: 45_000 }, + { name: "minutes", message: "Rate limit exceeded; please try again in 2 minutes", ms: 120_000 }, + { name: "hours", message: "quota exhausted, resets in 1 hour", ms: 3_600_000 }, + { name: "milliseconds", message: "rate limited, retry after 1500ms", ms: 1_500 }, + { name: "a fractional amount", message: "retry after 1.5 seconds", ms: 1_500 }, + ])("reads a relative wait stated in $name", ({ message, ms }) => { + expect(parseRateLimitRetryAt(message, NOW)).toBe(NOW + ms) + }) + + it("prefers the absolute deadline when the message states both", () => { + // The absolute form needs no arithmetic and no assumption about when the + // message was produced. + expect(parseRateLimitRetryAt("rate limited until 2026-08-05T16:27:33Z, retry after 5 seconds", NOW)).toBe(EXPECTED) + }) + + it.each([ + { name: "a zero wait", message: "retry after 0 seconds" }, + { name: "an unknown unit", message: "retry after 5 fortnights" }, + { name: "a duration with no retry wording", message: "the request took 30 seconds" }, + ])("returns undefined for $name", ({ message }) => { + expect(parseRateLimitRetryAt(message, NOW)).toBeUndefined() + }) }) describe("formatWait", () => { diff --git a/src/llm-gateway-error.ts b/src/llm-gateway-error.ts index 3b62b6561..34e92e0d8 100644 --- a/src/llm-gateway-error.ts +++ b/src/llm-gateway-error.ts @@ -149,15 +149,53 @@ export function parseModelRetiredInfo(rawMessage: string): ModelRetiredInfo | un const RATE_LIMIT_UNTIL_RE = /rate.?limited\s+until\s+([0-9T][0-9TZ:+.-]*[0-9Z])/i const EXPLICIT_ZONE_RE = /[Zz]$|[+-]\d{2}:?\d{2}$/ +// A plain 429 does not use the gateway's "rate limited until" wording. What it +// carries instead is a Retry-After, which reaches this extension only as text -- +// pi hands the message along and not the response -- rendered as a relative +// wait. Without this, "429 Too Many Requests, retry after 30 seconds" yields no +// deadline at all and the retry backs off blindly against a limit that had just +// said how long it lasts. +const RATE_LIMIT_IN_RE = + /(?:retry|try again|available|reset[s]?)\s*(?:after|in)\s+(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|secs?|seconds?|m|mins?|minutes?|h|hours?)\b/i + +const UNIT_MS: Record = { + ms: 1, + millisecond: 1, + milliseconds: 1, + s: 1_000, + sec: 1_000, + secs: 1_000, + second: 1_000, + seconds: 1_000, + m: 60_000, + min: 60_000, + mins: 60_000, + minute: 60_000, + minutes: 60_000, + h: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, +} + /** Epoch ms the gateway says the limit lifts, or undefined when it named none or it has passed. */ export function parseRateLimitRetryAt(rawMessage: string, now: number = Date.now()): number | undefined { const match = RATE_LIMIT_UNTIL_RE.exec(rawMessage) - if (!match?.[1]) return undefined - const stamp = match[1] - // The gateway reports UTC; Date.parse would read an unzoned stamp as local time. - const retryAt = Date.parse(EXPLICIT_ZONE_RE.test(stamp) ? stamp : `${stamp}Z`) - if (Number.isNaN(retryAt) || retryAt <= now) return undefined - return retryAt + if (match?.[1]) { + const stamp = match[1] + // The gateway reports UTC; Date.parse would read an unzoned stamp as local time. + const retryAt = Date.parse(EXPLICIT_ZONE_RE.test(stamp) ? stamp : `${stamp}Z`) + if (Number.isNaN(retryAt) || retryAt <= now) return undefined + return retryAt + } + + // The absolute form is preferred above because it needs no arithmetic; this + // is the fallback for a message that states a duration instead. + const relative = RATE_LIMIT_IN_RE.exec(rawMessage) + if (!relative?.[1]) return undefined + const amount = Number(relative[1]) + const unit = UNIT_MS[relative[2].toLowerCase()] + if (!Number.isFinite(amount) || amount <= 0 || unit === undefined) return undefined + return now + amount * unit } /** The gateway reports UTC; only local wall-clock time tells the user when to come back. */