Skip to content

Queue requests on upstream 429; upstream usage limits - #362

Merged
fylorn merged 10 commits into
mainfrom
integ/throttle
Oct 20, 2026
Merged

fylorn merged 10 commits into
mainfrom
integ/throttle

Conversation

@fylorn

@fylorn fylorn commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

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 200 and 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

  • One wait deadline per request: failover.wait_secs (default 120 s, 0–600) replaces slot_wait_secs. Key windows, concurrency slots, rate-limit pauses and upstream usage windows all spend from it.
  • Upstream 429 (not "quota used up"): the whole upstream pauses until 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 for retry-after-ms) count as missing; absurd values are capped by rate_limit_max_pause_secs. A 429 is no longer a failure: no breaker, no success-rate sample.
  • Queueing: a request first tries any candidate it can send to now; otherwise it waits on the paused / full upstreams (one FIFO queue per upstream) and sends to whichever recovers first. When a pause ends only the queue head is sent, as a probe; the rest follow once it passes (non-429 answer, judged at first content for held streams) or after a 10 s grace. Nothing is admitted after the request's deadline.
  • Single candidate: waited for, not bypassed (the bypass stays for the breaker only).
  • Sticky conversations wait for their own upstream when it can recover in time, and let go otherwise; skipped load-balance members are not charged.
  • Upstream usage limits use the key-limit format plus every ({ per: minute, every: 5, tokens: 10000000 }); every works 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.
  • Final 429 (deadline reached without sending): Retry-After / retry-after-ms for the earliest recovery and x-should-retry: true; when every candidate has used up its period, Retry-After is the earliest reset and x-should-retry: false.
  • Count-tokens requests never queue; they are estimated locally while the upstream is unavailable. WebSocket turns follow the same rules.

API (protocol 51)

  • ThrottleKind, Throttle; ProviderView.throttle, ProviderView.limits, ProviderInput.limits
  • Event::ThrottleChanged, Event::RequestQueued, Event::RequestDequeued
  • ServeSkip: throttled, window_full, period_used_up; AttemptView.pause_ms
  • KeyLimitView / KeyLimitInput.every; FailoverView.wait_secs (was slot_wait_secs)
  • New message codes: 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_secs is 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).
  • The default wait goes from 30 s to 120 s.

Testing

cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace pass 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

fylorn and others added 10 commits October 20, 2026 13:21
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>
@fylorn
fylorn merged commit e8f84f0 into main Oct 20, 2026
6 checks passed
@fylorn
fylorn deleted the integ/throttle branch October 20, 2026 08:35
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant