feat: add opt-in per-caller request rate limiting by Zheng-Lu · Pull Request #458 · vllm-project/agentic-api · GitHub
Skip to content

feat: add opt-in per-caller request rate limiting - #458

Merged
maralbahari merged 12 commits into
vllm-project:mainfrom
Zheng-Lu:feat/request-rate-limiting
Oct 22, 2026
Merged

maralbahari merged 12 commits into
vllm-project:mainfrom
Zheng-Lu:feat/request-rate-limiting

Conversation

@Zheng-Lu

Copy link
Copy Markdown
Contributor

Summary

Closes #457

The gateway had only global concurrency bounds, so one caller could use up the upstream's capacity and shared web-search or code-interpreter quotas for everyone. This PR adds an opt-in, per-caller request rate limit. It is off by default: no middleware is installed and no headers change.

Configuration (CLI > environment > [server] file setting, following max_request_body_size_bytes):

CLI Environment File ([server])
--rate-limit-requests-per-minute AGENTIC_RATE_LIMIT_REQUESTS_PER_MINUTE rate_limit_requests_per_minute
--rate-limit-burst AGENTIC_RATE_LIMIT_BURST rate_limit_burst (default: the per-minute rate)
  • A burst without a rate, a zero value, or a burst over one hour of requests fails at startup, and the error names the setting.
  • An explicit CLI value outranks a malformed inherited environment value.

Caller key (first match):

  1. OIDC issuer and subject;
  2. a keyed hash of the bearer token;
  3. a keyed hash of x-api-key;
  4. one shared anonymous bucket.

Raw credentials are never stored. A WebSocket is keyed by its upgrade request's own credentials, never by the server's OPENAI_API_KEY fallback.

Algorithm: GCRA (equivalent to a token bucket). Each caller holds one theoretical arrival time in a std::sync::Mutex<HashMap>, using tokio::time::Instant so tests can pause time. No lock is held across an await. Memory is bounded at 100,000 callers: idle callers are pruned first, and new callers share an overflow bucket while every tracked caller is active, with a warn at most once a minute. No new dependencies.

HTTP:

  • The middleware is a route_layer on the protected routes, layered before OIDC so that it runs after authentication and sees the principal. /health, /ready and Swagger are exempt.
  • A limited request never reaches the upstream. It gets 429 in OpenAI format (rate_limit_error / rate_limit_exceeded), or in Anthropic format with request-id on /v1/messages and count_tokens.
  • Headers: retry-after, retry-after-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests: 0, and x-ratelimit-reset-requests. The last is the time until the full burst returns, formatted like 1.5s or 2m3s.

WebSocket:

  • The upgrade is charged by the HTTP middleware, which also carries the caller's bucket into the connection.
  • Each client response.create is charged before any lane or byte capacity is reserved. A limited create gets an error frame with status: 429, and the connection stays open.
  • response.steer, response.inject and gateway-started continuations are not charged. The check sits where client messages arrive, not in WsMultiplexer::schedule, which also runs automatic continuations.

Structure:

  • rate_limit.rs holds the limiter, middleware, 429 builder and retry formatting.
  • server_settings.rs resolves the [server] table. Its first commit is a pure move of the existing body-size resolution out of main.rs, which was at its 644-line exception (now 616).
  • ServerConfig gains rate_limit, so AppState and its 16 test literals are unchanged.
  • auth.rs is at its line baseline. Its only change is visibility (AuthErrorFormat and for_path, reused for the 429 format), and bearer_token in auth/api_key.rs is also now crate-visible.

Scope:

Conflicts: a trial merge with #436, #440 and #420 is clean.

Test Plan

Unit tests:

  • rate_limit::tests (paused time):
    • burst, then one request per interval, including exact retry_after and reset;
    • steady state;
    • independent callers;
    • pruning before overflow;
    • credentials kept only as a hash (no token text in Debug; bearer and x-api-key share a key; Basic is anonymous);
    • retry rounding and x-ratelimit-reset-requests formatting;
    • the burst bound.
  • server_settings::tests:
    • off by default;
    • file, then environment, then CLI, including a CLI value over a malformed environment value;
    • the burst defaulting to the rate;
    • five invalid inputs, each named in its error;
    • flag parsing;
    • the generated [server] table.
  • config_file tests: the new keys parse, 0 is rejected, and an unset burst is not written.

tests/rate_limit_test.rs (one request per minute, so the outcomes are deterministic):

  • a_caller_over_its_limit_gets_an_openai_429_with_retry_headers: exact body and all five headers; the limited request never reaches the upstream; another bearer is unaffected.
  • callers_without_credentials_share_one_bucket
  • health_endpoints_are_never_limited
  • messages_routes_answer_in_the_anthropic_format_and_key_by_api_key: exact body; the request-id header matches the body.
  • rate_limiting_is_off_by_default: 10 requests and no rate-limit headers.
  • websocket_creates_are_charged_and_a_limited_create_keeps_the_connection: the upgrade and two creates use a burst of 3; the next create gets the exact 429 frame; then response.steer is answered on the same connection.
  • http_and_websocket_share_a_bucket_and_the_upgrade_is_charged: a limited upgrade gets HTTP 429 before switching protocols.
  • a_websocket_without_credentials_is_not_charged_to_the_server_key

oidc_auth_test::rate_limits_key_oidc_callers_by_subject_not_token: two tokens for one subject share a bucket, and another subject has its own.

Negative controls (each applied to the finished code):

Change Result
Never install the HTTP layer 6 of the 8 integration tests fail; health and off-by-default pass
Drop the WebSocket charge Both WebSocket charging tests fail
Layer the limiter outside OIDC The OIDC subject test fails
Off-by-one in the GCRA comparison 4 unit tests fail
Disable pruning The pruning test fails

Binary smoke test at 1 request per minute:

  • --help lists both flags.
  • AGENTIC_RATE_LIMIT_BURST without a rate fails at startup: AGENTIC_RATE_LIMIT_BURST requires AGENTIC_RATE_LIMIT_REQUESTS_PER_MINUTE.
  • The first /v1/models request reaches the upstream; the second gets 429 with all headers; /health still returns 200.

Gate:

  • cargo fmt -- --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • Feature clippy (--features embedded-code-interpreter) for both crates
  • The Rust file-size ratchet
  • pre-commit run --all-files
  • mkdocs build --strict
  • cargo test --workspace --no-fail-fast: 2530 passed, 1 failed. The failure is tool::web_fetch::http::tests::the_deadline_bounds_a_slow_origin, which also fails on unmodified main when the whole lib suite runs in parallel.

Move the request-size resolution out of main.rs into server_settings.rs,
unchanged. main.rs is at its file-size exception, and the next [server]
setting needs room next to this one.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
A shared gateway had only global concurrency bounds, so one caller could
use up the upstream's capacity and paid tool quotas for everyone.
`--rate-limit-requests-per-minute`, `AGENTIC_RATE_LIMIT_REQUESTS_PER_MINUTE`
or `[server] rate_limit_requests_per_minute` now turns on a per-caller
limit, with an optional burst. It is off by default.

- Callers are keyed by OIDC issuer and subject, otherwise by a keyed hash
  of the bearer token or `x-api-key`, otherwise by one anonymous bucket.
  Raw credentials are never stored.
- The limiter uses GCRA over a bounded map. Idle callers are pruned, and
  callers beyond the cap share an overflow bucket. The lock is never held
  across an await.
- The middleware runs inside OIDC authentication on every protected
  route. A limited request gets 429 in OpenAI or Anthropic format with
  retry-after, retry-after-ms and x-ratelimit-* headers, and never
  reaches the upstream.
- A WebSocket is charged for its upgrade and for each client
  `response.create`, from the upgrade request's own credentials. A
  limited create gets an error frame, and the connection stays open.
  Steering, injection and gateway-started continuations are not charged.

Closes vllm-project#457

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
@Zheng-Lu Zheng-Lu changed the title feat: add opt-in per-caller request rate limiting" feat: add opt-in per-caller request rate limiting Oct 17, 2026
Comment thread crates/agentic-server/src/rate_limit.rs Outdated
Once the map held MAX_KEYS active callers, every request from a new key
scanned the whole map under the shared lock. A client rotating bearer
values could make each of its requests an O(100,000) critical section
for every caller.

Sweeps of a full map now run at most once per SWEEP_INTERVAL (1 s). New
callers in between share the overflow bucket without scanning. In
release builds, 2,000 new-key requests against a full 100,000-caller map
take 0.66 ms instead of 349 ms.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Comment thread crates/agentic-server/src/rate_limit.rs Outdated
Comment thread crates/agentic-server/src/rate_limit.rs Outdated
Comment thread crates/agentic-server/src/rate_limit.rs Outdated
Comment thread crates/agentic-server/src/rate_limit.rs Outdated
retry_after_seconds and retry_after_millis truncated to milliseconds and
microseconds before rounding up, so a wait of 1 s + 1 ns was advertised
as `retry-after: 1`. That is shorter than the limiter requires, while
`Retry-After` is the minimum wait. Both now round up from nanoseconds.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
One untracked caller keeps sending while the key map stays full. The
overflow bucket rejects all but one request, and none of them rescans
the map. The test fails on the code before the sweep interval, where
each rejected request scanned every caller under the lock.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
The rate limiter repeated auth's choice between the OpenAI and Anthropic
envelopes and its request-ID handling, and both built their bodies with
json!. error_response.rs now holds one GatewayError builder with typed
envelopes and a dynamic message. Each caller adds its own headers:
WWW-Authenticate for authentication, retry hints for rate limits.

auth.rs drops from 791 to 727 production lines, and its size baseline
is lowered to match.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Embedders configure rate limiting through the public RateLimitConfig;
the limiter itself is used only inside the crate. Drop its unused
config() accessor, since the middleware reads the field directly.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
@Zheng-Lu
Zheng-Lu requested a review from maralbahari October 19, 2026 16:41
Comment thread crates/agentic-server/tests/rate_limit_test.rs Outdated
Comment thread docs/api/index.md Outdated

The gateway keeps only a keyed hash of a token or API key, never the credential. Without OIDC, the gateway does not
verify these credentials (the upstream does), so they identify callers for rate limiting only. A WebSocket is charged
to the caller of its upgrade request, never to the server's `OPENAI_API_KEY`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Without OIDC these keys are whatever the client sends, so the limit only binds callers that reuse a credential. In a scratch test at 1 request per minute, 20 requests that each carried a new made-up bearer all returned 200 and reached the upstream. A limited client without credentials also got through again just by adding any bearer, and x-api-key behaves the same. Could the docs say this plainly, e.g. "without OIDC, a client can get a fresh bucket by changing its credential; enable OIDC to enforce per-caller limits against untrusted clients"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1b77d39. The API docs now say that without OIDC the limit only binds clients that reuse a credential: a client gets a fresh bucket by sending a different bearer token or x-api-key, and a client without credentials leaves the shared bucket by adding any. They also recommend enabling OIDC to enforce per-caller limits against untrusted clients. The README and the Kubernetes guide say the same.

The tests already cover both escapes: a_caller_over_its_limit_gets_an_openai_429_with_retry_headers shows another bearer gets its own bucket, and callers_without_credentials_share_one_bucket shows adding a bearer leaves the anonymous bucket. With OIDC, rate_limits_key_oidc_callers_by_subject_not_token shows a new token for the same subject does not reset the bucket.

Comment thread crates/agentic-server/src/rate_limit.rs Outdated

@haoshan98 haoshan98 Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A fresh x-api-key escapes the limit while the gateway pays with its server key

When the gateway runs the request itself (store: true, the default), a client can send a new made-up x-api-key (or a lowercase bearer token) on each request. Each value gets its own bucket, and the gateway never forwards it: it calls the upstream with its own server key, so nobody ever checks the credential. With the limit set to 1 request per minute, five such requests all reached the upstream as Bearer server-key; it still reproduces on the new head. This is why the 200s in @Mohammad-nassar10's thread happen, and why the doc's "the upstream does" verification doesn't hold on this path.

Suggested fix: don't key the bucket on a credential the gateway doesn't verify. Use the verified identity when OIDC is on; without it, key on something the client can't mint for free (for example the peer address), and document that per-caller limits need OIDC against untrusted clients.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 3d32f92, as you suggested:

  • A request is charged to the verified OIDC principal, otherwise to the connection's peer address.
  • An IPv6 address counts as its /64, since one host commonly holds a whole /64, and an IPv4-mapped address counts as its IPv4 caller.
  • A bearer token or x-api-key never chooses the bucket.
  • The server now records each connection's peer address. gateway_listener returns the concrete tapped listener, so axum can provide ConnectInfo.

Regression test, changing_the_credential_does_not_reset_the_bucket:

  • It mirrors your reproduction: 1 request per minute, a store: true executor request with a server key configured, then a new x-api-key, a new Bearer, a lowercase bearer, and no credential.
  • All four are now limited, and the upstream sees one request.
  • It failed before the fix: the fresh x-api-key was processed.

separate_peer_addresses_have_separate_buckets covers the new keying through the router with injected peer addresses: the same address on a new port is the same caller, another address has its own bucket, and one IPv6 /64 is one caller.

On the real binary, from 127.0.0.1 the first request is admitted, and rotating x-api-key, using a lowercase bearer, or dropping the credential then gets 429. From 127.0.0.2 a request is admitted.

At one request per minute, the advertised wait is 60 s less the time
since the bucket's first request. The integration tests asserted exactly
60 s, so a run slower than one second (as under parallel load) failed on
a correct `59`. They now check each hint against the measured elapsed
time, and check the hints against each other exactly: `retry-after` is
`retry-after-ms` rounded up, the reset is that wait plus one interval per
extra burst request, and the message quotes `retry-after`. The paused-time
unit tests still pin the exact rounding.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Without OIDC, the gateway keys callers by credentials it does not
verify, so a client gets a fresh bucket by changing its bearer token or
`x-api-key`, and a client without credentials leaves the shared bucket
by adding any. Say so, and recommend OIDC for untrusted clients.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
A bearer token or `x-api-key` chose the bucket even though the gateway
does not verify either. On executor routes the gateway may call the
upstream with its own key, so nothing ever checked them. A client could
send a fresh made-up `x-api-key`, or a lowercase `bearer` token, with
every request and never be limited.

Requests are now charged to the verified OIDC principal, otherwise to the
connection's peer address. An IPv6 address counts as its /64, since one
host commonly holds a whole /64. The server records each connection's
peer address (`gateway_listener` now returns the concrete tapped
listener so axum can provide `ConnectInfo`). A request with no known
peer, from a router served without connection info, shares one bucket.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Callers are told apart by OIDC identity, otherwise by peer address,
never by a credential the gateway does not verify. Behind a proxy or
ingress every request shares the proxy's address, so per-caller limits
need OIDC. Forwarded-for headers are not trusted.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
@Zheng-Lu
Zheng-Lu requested a review from haoshan98 October 20, 2026 19:42
@Zheng-Lu
Zheng-Lu requested a review from maralbahari October 20, 2026 19:42

@maralbahari maralbahari left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@Zheng-Lu LGTM.
could you please update ARCHITECTURE.md to describe rate_limit.rs, error_response.rs, server_settings.rs, and authentication before rate-limit admission.

@maralbahari
maralbahari merged commit a7dc7b5 into vllm-project:main Oct 22, 2026
17 checks passed
haoshan98 added a commit to EmbeddedLLM/agentic-api that referenced this pull request Oct 22, 2026
Brings in vllm-project#458 (opt-in per-caller request rate limiting). Merged without
conflicts.

Signed-off-by: haoshan98 <haoshanw@gmail.com>
@Zheng-Lu
Zheng-Lu deleted the feat/request-rate-limiting branch October 23, 2026 18:42
maralbahari added a commit that referenced this pull request Oct 26, 2026
…#480)

## Summary

Follow-up to #458, as requested in @maralbahari's approval:
#458 (review).
Documentation only.

`ARCHITECTURE.md` now describes:
- **Request admission** (new section). Protected routes run OIDC
(`require_oidc`), then rate-limit admission (`enforce_rate_limit`), then
the retrieval-only API-key check, then the handler. Authentication comes
first because the limiter keys a bucket by the verified principal
whenever there is one. The section also explains why the rate-limit
`route_layer` is added before the OIDC one, and which routes sit outside
both layers.
- **`rate_limit.rs`** (new section): GCRA buckets, keys (OIDC principal,
otherwise the peer address with IPv6 grouped by /64; never unverified
credentials or forwarded-for headers), the `MAX_KEYS` and overflow
bound, the retry hints, and the per-frame WebSocket charge through
`RateLimitCharge`.
- **`error_response.rs`** (new section): `GatewayError` and the OpenAI
or Anthropic envelope chosen by `ErrorFormat::for_path`, used for
authentication failures and rate limits.
- **`server_settings.rs`**: the `[server]` admission settings and their
CLI > environment > file > default resolution.
- **Smaller updates:**
- `rate_limit` is added to the library exports and the gateway's module
table;
  - `server.rs` serves with connect info;
  - `auth.rs` mentions `require_api_key`;
  - a new "Change request admission" row in the quick reference.

## Test Plan

- Checked each statement against `main` at a7dc7b5:
  - the layer order in `build_router_with_auth`;
  - `RateLimitKey::for_caller`;
  - `LimiterState`;
- the WebSocket `RateLimitCharge::check` path, which sends an error
event and keeps the connection open;
  - the OIDC 401 and 503 responses;
  - `server_settings::resolve_rate_limit_values`.
- `pre-commit run --files ARCHITECTURE.md` passes.

Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Co-authored-by: Maral <maralbahari.98@gmail.com>
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.

feat: Add opt-in per-caller request rate limiting

5 participants