-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(providers): park a key until the reset instant the upstream declared #4733
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
abhisheksharma2411
wants to merge
3
commits into
lidge-jun:dev
from
abhisheksharma2411:feat/openrouter-quota-reset-cooldown
Closed
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
19479ae
feat(providers): park a key until the reset instant the upstream decl…
abhisheksharma2411 8076341
fix(providers): bound the 429 body READ, and stop cloning it
abhisheksharma2411 a137d4c
fix(providers): refuse a reset date the calendar does not have, and p…
abhisheksharma2411 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
154 changes: 154 additions & 0 deletions
154
tests/providers/openrouter-quota-reset-cooldown-4024.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { parseQuotaResetAt, readQuotaResetAt } from "../../src/providers/key-failover"; | ||
|
|
||
| /** | ||
| * #4024 — a free-tier quota exhaustion is dated by the upstream, and OpenRouter | ||
| * sends it in the 429 body rather than in `Retry-After`. Without reading it the | ||
| * key is parked for the undated-429 cap (10 min), comes back, takes another 429, | ||
| * and repeats for the rest of the quota window. | ||
| */ | ||
| describe("parseQuotaResetAt", () => { | ||
| const now = Date.parse("2026-09-01T00:00:00Z"); | ||
|
|
||
| test("reads the OpenRouter wording, treating a bare timestamp as UTC", () => { | ||
| const body = JSON.stringify({ | ||
| error: { code: "rate_limit_error", message: "Weekly Limit Exhausted. Your limit will reset at 2026-09-09 03:30:06" }, | ||
| }); | ||
| expect(parseQuotaResetAt(body, now)).toBe(Date.parse("2026-09-09T03:30:06Z")); | ||
| }); | ||
|
|
||
| test("honours an explicit zone rather than re-stamping it as UTC", () => { | ||
| const at = parseQuotaResetAt("limit will reset at 2026-09-09T03:30:06+05:30", now); | ||
| expect(at).toBe(Date.parse("2026-09-09T03:30:06+05:30")); | ||
| expect(at).not.toBe(Date.parse("2026-09-09T03:30:06Z")); | ||
| }); | ||
|
|
||
| test("accepts the 'resets at' spelling and a date with no clock time", () => { | ||
| expect(parseQuotaResetAt("quota resets at 2026-09-09", now)).toBe(Date.parse("2026-09-09T00:00:00Z")); | ||
| }); | ||
|
|
||
| test("a body it cannot read yields undefined, so today's behaviour is unchanged", () => { | ||
| for (const body of [ | ||
| null, | ||
| undefined, | ||
| "", | ||
| "429 Too Many Requests", | ||
| JSON.stringify({ error: { message: "rate limited, try later" } }), | ||
| "will reset at soon", | ||
| "will reset at 2026-13-45 99:99:99", | ||
| ]) { | ||
| expect(parseQuotaResetAt(body as string | null | undefined, now)).toBeUndefined(); | ||
| } | ||
| }); | ||
|
|
||
| test("a reset already in the past is not a park-until instant", () => { | ||
| expect(parseQuotaResetAt("will reset at 2026-08-01 00:00:00", now)).toBeUndefined(); | ||
| }); | ||
|
|
||
| test("a monthly window is honoured in full, not clamped", () => { | ||
| // `Weekly/Monthly Limit Exhausted` is the wording upstream sends, so a reset | ||
| // up to ~31 days out is legitimate. Clamping it would resume the 429 loop | ||
| // weeks early — the failure this feature exists to prevent. | ||
| const monthly = "will reset at 2026-10-01 00:00:00"; | ||
| expect(parseQuotaResetAt(monthly, now)).toBe(Date.parse("2026-10-01T00:00:00Z")); | ||
| }); | ||
|
|
||
| test("an absurd or hostile date is capped rather than parking the key forever", () => { | ||
| const at = parseQuotaResetAt("will reset at 2999-01-01 00:00:00", now); | ||
| expect(at).toBe(now + 32 * 24 * 60 * 60_000); | ||
| }); | ||
|
|
||
| test("a day the calendar does not have is refused, not rolled forward", () => { | ||
| // `Date.parse` does not reject an out-of-range DAY — measured on Bun, | ||
| // `2026-02-30T00:00:00Z` yields March 2 — so without this the key parks | ||
| // past the instant the upstream actually named. Only the month is caught | ||
| // by the parser itself. | ||
| const feb = Date.parse("2026-02-25T00:00:00Z"); | ||
| expect(parseQuotaResetAt("resets at 2026-02-30T00:00:00Z", feb)).toBeUndefined(); | ||
| expect(parseQuotaResetAt("resets at 2026-02-29T00:00:00Z", feb)).toBeUndefined(); | ||
| expect(parseQuotaResetAt("resets at 2026-04-31T00:00:00Z", Date.parse("2026-04-25T00:00:00Z"))).toBeUndefined(); | ||
| expect(parseQuotaResetAt("resets at 2026-13-01T00:00:00Z", feb)).toBeUndefined(); | ||
| }); | ||
|
|
||
| test("real leap days still park the key, including the century rule", () => { | ||
| // The guard above must not cost a legitimate Feb 29. 2024 is a leap year, | ||
| // 2000 is one (divisible by 400) and 2100 is not (divisible by 100). | ||
| expect(parseQuotaResetAt("resets at 2024-02-29T00:00:00Z", Date.parse("2024-02-25T00:00:00Z"))) | ||
| .toBe(Date.parse("2024-02-29T00:00:00Z")); | ||
| expect(parseQuotaResetAt("resets at 2000-02-29T00:00:00Z", Date.parse("2000-02-25T00:00:00Z"))) | ||
| .toBe(Date.parse("2000-02-29T00:00:00Z")); | ||
| expect(parseQuotaResetAt("resets at 2100-02-29T00:00:00Z", Date.parse("2100-02-25T00:00:00Z"))) | ||
| .toBeUndefined(); | ||
| }); | ||
|
|
||
| test("only the first 4KB is scanned, so a huge body cannot stall the rotation path", () => { | ||
| const padded = "x".repeat(8_000) + " will reset at 2026-09-09 03:30:06"; | ||
| expect(parseQuotaResetAt(padded, now)).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("readQuotaResetAt", () => { | ||
| const now = Date.parse("2026-09-01T00:00:00Z"); | ||
|
|
||
| test("returns the reset AND a response whose body is still fully readable", async () => { | ||
| // The caller still needs this response: on a failed rotation adapter-dispatch | ||
| // breaks out of the loop with it, and on a successful one it cancels the body | ||
| // to release the socket. Peeking must not cost it either. | ||
| const body = JSON.stringify({ | ||
| error: { code: "rate_limit_error", message: "Weekly Limit Exhausted. Your limit will reset at 2026-09-05 12:00:00" }, | ||
| }); | ||
| const { at, response } = await readQuotaResetAt(new Response(body, { status: 429 }), now); | ||
|
|
||
| expect(at).toBe(Date.parse("2026-09-05T12:00:00Z")); | ||
| expect(response.status).toBe(429); | ||
| // The bytes already pulled are replayed ahead of the remainder. | ||
| expect(await response.text()).toBe(body); | ||
| }); | ||
|
|
||
| test("the returned response can be cancelled instead of read", async () => { | ||
| const { response } = await readQuotaResetAt(new Response("x".repeat(10_000), { status: 429 }), now); | ||
| await response.body?.cancel(); | ||
| expect(response.bodyUsed).toBe(true); | ||
| }); | ||
|
|
||
| test("a bodyless or unreadable response leaves the Retry-After path in charge", async () => { | ||
| expect((await readQuotaResetAt(new Response(null, { status: 429 }), now)).at).toBeUndefined(); | ||
| const consumed = new Response("x", { status: 429 }); | ||
| await consumed.text(); | ||
| expect((await readQuotaResetAt(consumed, now)).at).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("readQuotaResetAt — the read is bounded, not just the parse", () => { | ||
| const now = Date.parse("2026-09-01T00:00:00Z"); | ||
|
|
||
| test("stops pulling after the cap instead of buffering the whole body", async () => { | ||
| // A chatty upstream must not make the rotation path read megabytes. This counts | ||
| // what the reader actually PULLED, not what the parser looked at — the two were | ||
| // different before this was fixed (`.text()` read it all, then sliced 4KB). | ||
| let pulled = 0; | ||
| const chunk = new TextEncoder().encode("x".repeat(64 * 1_024)); | ||
| const total = 5 * 1_024 * 1_024; | ||
| const body = new ReadableStream<Uint8Array>({ | ||
| pull(controller) { | ||
| if (pulled >= total) { | ||
| controller.close(); | ||
| return; | ||
| } | ||
| pulled += chunk.byteLength; | ||
| controller.enqueue(chunk); | ||
| }, | ||
| }); | ||
|
|
||
| const { at } = await readQuotaResetAt(new Response(body, { status: 429 }), now); | ||
|
|
||
| expect(at).toBeUndefined(); | ||
| expect(pulled).toBeLessThan(total / 4); | ||
| }); | ||
|
|
||
| test("still finds a reset that sits inside the cap", async () => { | ||
| const body = `{"error":{"message":"Weekly Limit Exhausted. Your limit will reset at 2026-09-05 12:00:00"}}`; | ||
| expect((await readQuotaResetAt(new Response(body, { status: 429 }), now)).at) | ||
| .toBe(Date.parse("2026-09-05T12:00:00Z")); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Restrict quota-reset parsing to OpenRouter quota responses.
src/server/responses/adapter-dispatch.ts:642-656callsreadQuotaResetAtfor any non-OAuth provider with at least twoapiKeyPoolentries.src/providers/key-failover.ts:154accepts anyreset atorresets atphrase.rotateKeyAfterFailuregivesquotaResetAtprecedence overRetry-After, so an unrelated provider response can park the failed key for up to 32 days.Require the OpenRouter quota-exhaustion signature, or add a canonical provider capability gate. Replace the broad
"quota resets at"test with a non-OpenRouter negative case.🧰 Tools
🪛 OpenGrep (1.28.0)
[ERROR] 154-154: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.
(coderabbit.command-injection.exec-js)
🤖 Prompt for AI Agents