Repository navigation
Queue requests on upstream 429; upstream usage limits - #362
Merged
Merged
Conversation
Config: a usage limit takes `every` (1-60 for `minute`, 1-24 for `hour`; not for day/week/month), and `KeyLimit::window_ms()` is the rolling window length every counting site now uses. Upstreams take `limits` with the same entries and rules as keys, validated with their own `config.upstream_limit_*` codes; keys gain `config.key_limit_every` and `config.key_limit_every_calendar`. Gateway: the period/window/reservation bookkeeping of key limits moves to `key_limits::books` unchanged, and the new `upstream_limits` module builds on it. Each hop sent to an upstream reserves a request plus an input estimate; a 429 or unsent hop releases it, any other outcome is bound to the request id and settled from the stored row (requests from the attempt chain, tokens and cost on the serving hop). `state()` reports Free / WindowFull / PeriodUsedUp with the recovery time and `changed()` resolves when that may have changed. Calendar periods are rebuilt and re-read from the request records like key limits. Key limits with `every` > 1 refuse with new `gw.key_limit.*_every` messages that name the window. API: `KeyLimitView`/`KeyLimitInput` carry `every`; `ProviderView` and `ProviderInput` carry `limits`. The control plane writes them on create and update, shows live usage, and a rename keeps the usage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An upstream that answers 429 (not quota used up) is no longer a failure: it is paused (retry-after-ms, Retry-After, body delay, else backoff 5/10/20/40/60 s by consecutive 429s, capped by rate_limit_max_pause_secs) and never feeds the breaker or the success rate. Requests skip paused, window-full and busy upstreams, then wait for the first one to become sendable within one per-request deadline (failover.wait_secs, renamed from slot_wait_secs, default 120, max 600). One FIFO queue per upstream; after a pause only the queue head goes as a probe. Period-used-up upstreams are skipped. A final 429 names each upstream's state with Retry-After / retry-after-ms / x-should-retry. Also: sticky conversations wait for or release their upstream, count tokens is estimated while throttled, WebSocket turns wait the same way, load-balance does not charge skipped members, and a rate-limit error inside a stream still pauses the upstream. API: ThrottleKind, Throttle, ProviderView.throttle, Event::ThrottleChanged, Event::RequestQueued, ServeSkip throttled / window_full / period_used_up, AttemptView.pause_ms, FailoverView.wait_secs. upstream_limits.rs is a stub for the usage limits module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers a lone upstream waited out after a 429, probe-first release, the final 429 headers when no pause ends in time, count tokens estimated while paused, aborting a queued request, sticky wait versus skip, and 429s kept out of the breaker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps the key-limit ledger split into key_limits/books.rs and C1's request_wait() on failover.wait_secs; AppState carries both upstream_limits and throttles. Docs merge both lanes' text with slot_wait_secs renamed to wait_secs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Upstream usage limits come from providers[].limits; the hop loop, WebSocket turns and ProviderView.throttle now see real window_full / period_used_up state. - A hop that was rate limited (pause_ms) does not count toward an upstream's limits, in memory and when rebuilt from records alike: quota 429s give back their reservation at once, and a WebSocket turn rate limited before any content carries pause_ms and releases its hold. - Usage-limit times are converted to the throttle clock, so a test clock on the limits no longer disagrees with deadlines; swapping the limits clock rebuilds the throttles over the new books. - Reserving wakes watchers, so a window that fills is reported right away. - End-to-end tests: a full minute window waits then sends, every: 5, period used up skips then refuses with x-should-retry: false, 429'd and unsent hops leave no usage behind; WebSocket and tw-api counting tests. - CONTROL_API_VERSION 50 -> 51. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four end-to-end cases that fail on the current queue and pin down defects to fix before the PR: - a probe that is rate-limited again sits at the head of the upstream's queue while it tries the next candidate, so nobody else may pass the upstream after its pause has ended - an absurd retry-after-ms panics in Duration::from_secs_f64 and drops the client connection - Retry-After: 0 resends in a tight loop and keeps going past the wait deadline (first_ready grants before it checks the deadline) - the probe grace end is treated as a known recovery time, so a request whose deadline is nearer is refused at once instead of waiting for the probe's verdict Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Never admit a waiting request after its deadline; a Retry-After under one second (retry-after-ms under one millisecond) counts as not given, so the backoff applies instead of a resend loop. - A probe that is rate-limited again keeps its place at the head of the upstream's queue only for its next wait; while it tries other candidates it holds no queue. - An absurd retry-after-ms or retryDelay is capped instead of panicking. - A probe in flight has no known recovery time: requests whose deadline falls inside the grace wait for its verdict; the grace is only used to estimate the client's Retry-After. - Responses held for their opening give the probe verdict (and reset the backoff) at the first content, not at the 200 head; WebSocket turns likewise. The last candidate's stream is held too while its upstream may be rate limiting (probing or limited in the last five minutes), so an in-stream rate limit before content queues instead of reaching the client. - A probe that gets no verdict (connect error, idle timeout, broken stream, quota used up or no balance) releases the queue to normal health handling; waiting requests drop upstreams health has set aside while another is still available, and stop at once with the used-up 429 when every waited-for upstream used up its period. - WebSocket turns bind their usage reservation before reporting the ending. - Renaming an upstream moves its pause, backoff, probe and queue. - Pause and grace run on a monotonic clock; reported times stay Unix ms. - New event RequestDequeued (also in /in-flight) ends a RequestQueued. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- renaming an upstream while an unsent probe ticket is out wedges the renamed upstream (the ticket never settles or returns) - a request with wait_secs 0 that finds its upstream busy is reported as RequestQueued/RequestDequeued although it never waits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A probe ticket is resolved by its number, not the upstream name: a rename between taking the ticket and sending (or dropping) it no longer leaves the renamed upstream waiting on a ticket nobody settles. - A request whose wait deadline has already passed (wait_secs 0, or waited out on key limits) stops before the final wait instead of emitting a zero-length RequestQueued/RequestDequeued pair. - Pauses, the probe grace and the "recently rate limited" window end when either the monotonic or the wall clock has run their length, so time spent asleep counts while setting the clock back still cannot stretch them. Timers wake at least once a second to notice a sleep. - "Answers to requests sent before the latest 429 do not count" now compares monotonic send instants instead of wall-clock milliseconds with a one-second slack. - Usage-limit times are converted with one read of each clock and left unchanged when the two clocks agree, so ThrottleChanged no longer repeats with until_ms one millisecond apart. - Reloading replaces the usage-limit watcher tasks instead of piling them up; watchers of removed or renamed upstreams are aborted, and all of them stop when the throttles are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Summary
An upstream that rate-limits (429) no longer turns into errors for every client request. The gateway pauses that upstream, queues requests for it, and resends them once it recovers; streaming clients keep their early
200and keep-alives while they wait, so a well-behaved agent only sees a slower first token. Upstreams can also carry their own usage limits (providers[].limits), so the gateway can stay under a relay's "N tokens per M minutes" rule before it ever answers 429.Behaviour
failover.wait_secs(default 120 s, 0–600) replacesslot_wait_secs. Key windows, concurrency slots, rate-limit pauses and upstream usage windows all spend from it.retry-after-ms/Retry-After/ the body's delay, else backoff 5, 10, 20, 40, then 60 s (reset on success). Hints under 1 s (under 1 ms forretry-after-ms) count as missing; absurd values are capped byrate_limit_max_pause_secs. A 429 is no longer a failure: no breaker, no success-rate sample.every({ per: minute, every: 5, tokens: 10000000 });everyworks for key limits too. Minute/hour windows full → wait or switch; a used-up day/week/month → skip, never wait. Requests count per hop sent and not answered 429; tokens and cost settle from the serving hop; calendar periods are rebuilt from request records on restart.Retry-After/retry-after-msfor the earliest recovery andx-should-retry: true; when every candidate has used up its period,Retry-Afteris the earliest reset andx-should-retry: false.API (protocol 51)
ThrottleKind,Throttle;ProviderView.throttle,ProviderView.limits,ProviderInput.limitsEvent::ThrottleChanged,Event::RequestQueued,Event::RequestDequeuedServeSkip:throttled,window_full,period_used_up;AttemptView.pause_msKeyLimitView/KeyLimitInput.every;FailoverView.wait_secs(wasslot_wait_secs)config.key_limit_every*,config.upstream_limit_*,gw.key_limit.*_every,gw.upstream.{paused,window_full,period_used_up},gw.wait.{rate_limited,window_full,used_up,mixed}Upgrade notes
failover.slot_wait_secsis gone with no alias: a config that sets it starts in safe mode, and the repair removes the key (its value is not carried over).Testing
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warningsandcargo test --workspacepass locally (3932 tests), including new end-to-end tests for the queue, probe, backoff, deadline headers, usage windows, renames, sleep-safe pauses and WebSocket turns.🤖 Generated with Claude Code