Repository navigation
fix(limits): enforce route caps and close the places limits leaked - #94
Merged
Merged
Conversation
Each of these was a way past a limit, or a limit that did nothing:
- Loading a request's limits from Postgres fell back to "no limits" on
any error, and a budget read swallowed Redis errors as 0, so the
budget gate's fail-closed branch could never run. Both now follow
security.rate_limit_fail_closed like the rate limiter does: open runs
the request and logs the error, closed answers the existing
limiter-unavailable 429 (limits_unavailable / budget_unavailable).
- model_routes.rpm_cap / tpm_cap were loaded and never read. They are
now enforced as the schema and the route editor describe: a route at
its cap is skipped like a broken one, and with every candidate capped
the request gets 429 with Retry-After. Counted on the sliding-window
engine under a {route:<id>} hash tag; tokens are weighted and added
after the response.
- The rate-limit stage, which charges the request counters, ran before
the model / tool access check on both gateways, so a refused request
spent a request. The order is now budget, access, limits, and the
lifecycle type states enforce it.
- Role-level limits were merged from every role assignment while gateway
access reads only global and team-attached roles. A team-scoped grant
administers the team and grants nothing at the gateway, as the UI
says, so its limits no longer apply either: one loader decides both.
- A cache hit counted a request but no tokens, so repeating a cached
request got round token limits and budgets. It now counts the tokens
the cached answer records.
- The legacy monthly quota counted into quota:* keys against a limit
nothing could set. Removed with its metric; budgets cover it.
Also corrects comments that described a limits cache, a limits:changed
subscriber and team budget caps, none of which exist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fylorn
added a commit
that referenced
this pull request
Oct 10, 2026
Resolves the overlap with #94: cached answers are accounted once, through post_flight_account, which also records the key and user usage counters (the separate count_cache_hit_usage is gone); usage::record_request runs after check_limits in the new budget, access, limits order; current_spend propagates a counter read error. Removes the gateway RateLimiter that was built into GatewayState and never called. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merged
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Fixes eight places where the gateway's limits leaked or did nothing. Each one was checked against the code before it was changed.
What changes
A limits load that fails follows
security.rate_limit_fail_closed(server/middleware/api_key_auth.rs). The key's and the user's limits were loaded withunwrap_or_default, so a database error meant the request ran with no limits. Now: fail open (the default) runs the request without them and logs a warning with the error (gateway_limits_load_fail_open_total). Fail closed refuses it (gateway_limits_load_fail_closed_total, plus an audit rowlimits_unavailable).check_limits) answers 429 +Retry-After: 30(GatewayError::limiter_unavailable), and the setting's hint in Settings says so. The setting should give one answer, so this uses the same one. It is labelledlimits_unavailable(Rate limited: limits_unavailable) and written in the caller's dialect. On MCP it is a JSON-RPC error with HTTP 429.A budget read that fails follows the same setting (
common/limits/budget.rs).current_spendswallowed the Redis error with.ok()and returned 0, so the fail-closed branch incheck_budgetcould never run. The error is now returned. Fail closed answers 429budget_unavailable, fail open goes on.Route caps are enforced (new
gateway/src/route_caps.rs, selection inproxy/routing.rs).model_routes.rpm_cap/tpm_capare documented indb/schema.sql("routes at cap are filtered out the same way circuit-broken routes are") and in the route editor ("enforced via the same sliding-window engine that drives api-key/user RPM/TPM, just with a route key"). Both match the expected semantics.route_caps::admitbefore the request is sent. That is one atomic check of both caps, and it counts the request when neither cap is reached. A route at a cap is skipped like an open breaker and the next candidate is picked.route:requests/1morroute:tokens/1m, with aRetry-Afterof when the first capped route has room. If some route did fail, its upstream error is the answer instead. This applies to buffered and streamed requests alike.sliding::admit_counters_at/record_counters_at, newly exposed). The window is one minute with 1-second buckets. Keys areratelimit:{route:<route_id>}:ai_gateway:route:<route_id>:{requests|tokens}:60, so one hash tag per route keeps them cluster-safe.post_flight_account, like the other token limits.rate_limit_fail_closed: fail closed answers 429rate_limiter_unavailable, fail open does not apply the cap.Access is checked before the request counts (
common/lifecycle,gateway/proxy/pipeline.rs,mcp-gateway/proxy.rs). The order is nowcheck_budget(read-only) →check_access→check_limitson both gateways. The type states changed to match:check_access(Raw) -> AccessCheckedcheck_limits(AccessChecked) -> AuthorizedLimitsCheckedis gone.The MCP
tools/callpath had the same ordering bug.Stale comments corrected. These comments described things that do not exist:
models/team.rs: there are no team caps.limits/mod.rs: there is noLimitsCache, and nothing subscribes tolimits:changed.weight.rs: the samelimits:changedpoint.check_cap,plan.md).No features were added.
One loader decides which roles apply at the gateway, for access and for limits (
auth/rbac.rs).The bug.
compute_user_role_constraintsread every role assignment (load_user_policy_documents), team-scoped grants included. Access read only global and team-attached roles (load_gateway_policy_documents). It now uses the gateway loader too, and the unusedload_user_role_ids(whose comment claimed the rate limiter used it) is removed.Why team-scoped grants are left out.
users.scopeTeamNoGateway)So: roles attached to a team apply (access and limits), and roles granted at team scope apply to neither.
What stays the same. Constraints already came only from the Allow statements for
*_gateway:use. Limits remain per surface, not per model.Cache hits count their tokens (
proxy/generate.rs). A hit now runspost_flight_accountwith the tokens the cached answer records, against the same user/key token rules and budgets the pre-flight checked. No route cap is involved, because no route answered. The cache stores only input/output totals, so all input counts as plain input. The cost a hit reports stays 0.Legacy monthly quota removed.
gateway/quota.rs(QuotaManager),GatewayState.quotaand its three calls ingenerate.rsare gone. It wrotequota:{…}:used:<month>against a limit nothing ever set. The Redis-cluster test drops its quota section and gains a route-cap script check.Also: the Settings label and hint for
security.rate_limit_fail_closed(en/zh) and its seeded description now describe everything the setting covers.CHANGELOG
[Unreleased]covers these:quota:*keys can be deleted).For the integrator (
feat/key-usage-endpoint)budget::current_spendbehaviour changed, signature unchanged. It already returnedResult<Vec<CapStatus>, fred::error::Error>, but it returnedOkwithcurrent: 0when a read failed. It now returnsErr. A usage reader that wants zeros on failure must handle theErr(the console handlers alreadyunwrap_or_default(), so on a Redis failure they now show no caps rather than zeroed caps).sliding::admit_at/record_atkeep their signatures and now delegate to the new publicsliding::admit_counters_at/record_counters_at(+sliding::Counter).current_countandResolvedRuleare untouched.api_key_auth.rs: the key's and user's constraints are now loaded byload_request_limitsand resolved bylimits_or_refusal. When loading fails open,GatewayRequestIdentity.key_constraintsis empty, so a/v1/usagehandler mounted behind this middleware would report no limits for that request. When it fails closed, the middleware refuses before the handler runs.rbac::compute_user_role_constraints/compute_user_surface_constraintskeep their signatures. They no longer include team-scoped role grants (item 6).rbac::load_user_role_idsis removed.GatewayErrorResponse::in_dialectis nowpub.GatewayState.quotais removed.LimitsCheckedis removed.check_accesstakesRawand returnsAccessChecked.check_limitstakesAccessCheckedand returnsAuthorized.Not changed
gateway::rate_limiter::RateLimiter(constructed intoGatewayState, never called) is left alone. It is out of scope; removing it is a follow-up.Verification
cargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings: clean.node web/scripts/check-i18n.mjs: parity holds (only two values changed;pnpm test/pnpm buildnot run).cargo nextest run --workspace --lib --bins(proxy env unset): 791 passedcargo nextest run -p think-watch-test-support --run-ignored only --profile ciwithSKIP_NETWORK_TESTS=1: 433 passed (the 11 new tests inlimit_enforcement.rsand 2 inteam_rbac.rsincluded)New unit tests:
budget::a_failed_read_is_an_error_not_zerocheck_budget::an_unreadable_budget_{refuses_when_failing_closed,passes_when_failing_open}check_limits::an_unreachable_store_{refuses_when_failing_closed,passes_when_failing_open}api_key_auth::{a_failed_limits_load_follows_the_fail_closed_setting,the_refusal_is_a_429_in_the_callers_format}route_caps::*(hash-tag slot, counters per cap, fail open/closed, the all-capped refusal)New integration tests in
tests/limit_enforcement.rs:Retry-After(buffered and stream)In
tests/team_rbac.rs:tests/redis_cluster.rshasthe_route_cap_scripts_run_on_a_cluster(needsTEST_REDIS_CLUSTER_URL; not run locally).Security notes
rate_limiter_unavailablerow does.🤖 Generated with Claude Code