Skip to content

Read a relative Retry-After out of the message, not only an absolute deadline - #1216

Open
unolife wants to merge 1 commit into
getkimchi:masterfrom
unolife:read-a-relative-retry-after-from-the-message
Open

unolife wants to merge 1 commit into
getkimchi:masterfrom
unolife:read-a-relative-retry-after-from-the-message

Conversation

@unolife

@unolife unolife commented Sep 17, 2026

Copy link
Copy Markdown

parseRateLimitRetryAt understands one phrasing — the gateway's rate limited until <timestamp>.

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:

{ name: "no deadline at all", message: "429 Too Many Requests" },

When no deadline is found, rateLimitWaitMs has nothing to report — so rateLimitNotice cannot say when the limit lifts, and prepareRetry falls back to exponential backoff against a limit that had just said how long it lasts.

What changed

A relative form is read as a fallback:

  • 429 Too Many Requests, retry after 30 seconds
  • Too many requests. Try again in 45s.
  • Rate limit exceeded; please try again in 2 minutes
  • quota exhausted, resets in 1 hour
  • rate limited, 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.

Testing

vitest run --dir src10949 passed, 17 skipped.

The new cases fail on the unmodified parser (6 failures) and pass with it, checked by restoring src/llm-gateway-error.ts from HEAD and re-running. pnpm run lint clean.

…deadline

`parseRateLimitRetryAt` understands one phrasing: the gateway's "rate limited
until <timestamp>". 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.
@readme-ai-writer

readme-ai-writer Bot commented Sep 17, 2026

Copy link
Copy Markdown

📚 No documentation changes were needed for this PR.

@kimchi-review

kimchi-review Bot commented Sep 17, 2026

Copy link
Copy Markdown

Kimchi Code Review

Property Value
Commit ee31f4e
Author @unolife
Files changed 2
Review status Completed
Comments 4 (3 info, 1 warning)
Duration 39s

Summary

📊 Review Score: 86/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 2/5 (1 = trivial, 5 = very complex)

🧪 Tests: yes — Tests are thorough: six positive variants covering all unit families (including fractional amounts and the ms abbreviation with no space), a precedence test for absolute-over-relative, and three negative cases (0 seconds, unknown unit, duration without retry wording). Minor gaps: no false-positive guard test (e.g. Service unavailable in 5 minutes), no test for an expired/invalid absolute timestamp combined with a valid relative duration, and no test for absurdly large durations.

📝 Found 4 issue(s). See inline comments for details.

What to expect

Kimchi will analyze the changes in this pull request and post:

  • A summary of the overall changes
  • Inline comments on specific lines with findings categorized by issue type

The review typically completes within a few minutes. This comment will be updated once the review is ready.

Interact with Kimchi
  • @getkimchi review — re-trigger a full review on the latest commit
  • @getkimchi summary — regenerate the PR summary
  • @getkimchi ignore — skip this PR (no review will be posted)
  • Reply to any inline comment to ask follow-up questions or request clarification
Configuration

Reviews are configured by your organization admin.
Review instructions, excluded directories, and severity thresholds can be adjusted per repository in the Kimchi dashboard.


Powered by Kimchi — AI-powered code review by CAST AI

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋 Thanks for your first PR on kimchi! A maintainer will review it soon.
Please make sure your PR links to an open issue and your checklist is complete.

@kimchi-review kimchi-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Review Score: 86/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 2/5 (1 = trivial, 5 = very complex)

🧪 Tests: yes — Tests are thorough: six positive variants covering all unit families (including fractional amounts and the ms abbreviation with no space), a precedence test for absolute-over-relative, and three negative cases (0 seconds, unknown unit, duration without retry wording). Minor gaps: no false-positive guard test (e.g. Service unavailable in 5 minutes), no test for an expired/invalid absolute timestamp combined with a valid relative duration, and no test for absurdly large durations.

📝 Found 4 issue(s). See inline comments for details.

Comment thread src/llm-gateway-error.ts
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️🐛 Bug

RATE_LIMIT_IN_RE has no leading word boundary on the trigger alternation, so available matches as a substring of unavailable and reset[s]? matches inside words like presets. A message such as Service unavailable in 5 minutes (a plausible non-rate-limit gateway error) would be misread as a rate-limit hint and produce a bogus retry deadline 5 minutes out, delaying or scheduling retries on the wrong signal.

💡 Suggestion: Anchor the trigger group with a word boundary: /\b(?: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, and add a negative test for a message like Service unavailable in 5 minutes returning undefined.

Comment thread src/llm-gateway-error.ts
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]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️⚠️ Error Handling

When RATE_LIMIT_UNTIL_RE matches but the captured timestamp is unparsable or already in the past, the function returns undefined immediately and never attempts the new relative fallback. A message that states both forms with a stale absolute stamp (e.g. a delayed/replayed rate limited until <past>, retry after 5 seconds) loses a perfectly usable relative wait.

💡 Suggestion: Restructure so the relative path is tried when the absolute parse fails: extract the absolute-parsing into a block that falls through instead of returning (e.g. compute retryAt inside the if (match?.[1]) branch and only return retryAt when valid), or document that an expired absolute stamp intentionally suppresses the relative hint.

Comment thread src/llm-gateway-error.ts
// 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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️⚠️ Error Handling

Relative durations are unbounded: \d+ accepts arbitrarily large amounts, so retry after 100000000 hours yields a far-future timestamp and a ~300-digit amount produces Infinity from now + amount * unit (the Number.isFinite(amount) check only guards the parsed amount, not the product). Downstream callers feeding retryAt - now into a timer can hit runtime overflow clamping (e.g. Node's setTimeout overflow warning clamps to ~1 ms), turning an hours-long wait into an immediate retry storm.

💡 Suggestion: Clamp the computed delay to a sane ceiling before returning, e.g. const delay = Math.min(amount * unit, MAX_RETRY_WAIT_MS) with a constant such as 24 hours, and guard with Number.isFinite(delay).

])("returns undefined for $name", ({ message }) => {
expect(parseRateLimitRetryAt(message, NOW)).toBeUndefined()
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️🔧 Maintainability

The comment block above the new tests repeats the rationale paragraph from src/llm-gateway-error.ts (lines ~152-156) almost verbatim, so the two copies can drift apart as the wording evolves in one place only.

💡 Suggestion: Keep the full rationale in the source next to RATE_LIMIT_IN_RE and shorten the test comment to a one-line pointer, e.g. // Covers the relative Retry-After text form; see RATE_LIMIT_IN_RE for why this exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant