Skip to content

feat(ratelimit): scoped request, token, and concurrency limits (user paths, providers, models)#482

Merged
SantiagoDePolonia merged 11 commits into
mainfrom
feat/rate-limiting
Jul 6, 2026
Merged

feat(ratelimit): scoped request, token, and concurrency limits (user paths, providers, models)#482
SantiagoDePolonia merged 11 commits into
mainfrom
feat/rate-limiting

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What

Adds rate limiting with scoped rules: every rule targets a user_path subtree (consumer control), a provider instance (upstream protection), or a model — capping requests, tokens, and in-flight concurrency per sliding window. Complements budgets (spend control) with traffic control. Spec with the underlying gateway research (LiteLLM, Bifrost, Portkey, Kong, Cloudflare, Helicone) lives in docs/dev/2026-07-05_rate-limiting-spec.md; user docs in docs/features/rate-limits.mdx.

User-visible impact

  • Rules cap max_requests and/or max_tokens per minute/hour/day/custom window, plus a concurrent period for in-flight caps. A rule owns one shared counter for its whole subject: a /team rule covers the subtree (per-key limits = bind each managed key to its own user_path), a provider rule covers every consumer and model routed to that provider.
  • Scopes differ on breach. A user_path over its limit gets 429 with an OpenAI-shaped error (code: rate_limit_exceeded), an accurate sliding-window Retry-After, and x-ratelimit-{limit,remaining,reset}-{requests,tokens} headers from the most-constrained matching rule — OpenAI SDK backoff works out of the box. A saturated provider or model is treated like an unavailable target instead: virtual-model load balancing and failover route around it while capacity exists elsewhere, and only direct requests with no viable alternative get 429.
  • Model rule subjects: openai/gpt-4o pins one provider's model; bare gpt-4o caps the model across every provider. Matching is case-insensitive.
  • Enforcement covers every model endpoint: chat completions, responses, messages, embeddings, audio, passthrough, realtime sessions (the session holds its concurrency slot until the socket closes), and batch submission (counts one request, holds no slot, and skips provider/model rules since batch files can mix models). Response-cache hits bypass enforcement.
  • Managed via the dashboard Rate Limits page (scope selector in the editor, scope chips in the list), /admin/rate-limits (GET/PUT/DELETE + reset-one/reset; requests take scope+subject, with user_path as shorthand), a rate_limits: YAML block (user_paths/providers/models), and env vars: SET_RATE_LIMIT_<PATH>="rpm=100,tpm=50000,rpd=10000,concurrent=10" and SET_PROVIDER_RATE_LIMIT_<NAME>="rpm=500" (suffix underscores become hyphens like provider-instance env vars; model rules are YAML/admin-only). Config-sourced rules are read-only in the dashboard; manual edits win over config seeds (budget lifecycle).
  • Feature gate RATE_LIMITS_ENABLED defaults to true and is a no-op until rules exist, so existing deployments see zero behavior change. Pre-scope rate_limits tables/documents from earlier builds of this branch migrate in place at startup on SQLite/Postgres/Mongo.

Design notes / limitations (documented)

  • Request windows use an in-memory sliding-window counter (no 2× boundary bursts). Token limits are post-accounted from usage entries via a tap on the usage logger, so they require USAGE_ENABLED=true (startup warns otherwise) and one request can overshoot a token window — the same tradeoff Kong and pre-v3 LiteLLM make.
  • Provider/model token windows are charged to the executed route recorded on the usage entry, so accounting stays correct under aliasing and failover. Routing consults capacity through two read-only seams: a rate-limit-aware Catalog.ModelAvailable decorator for virtual-model target selection (same skip semantics as stale provider inventory) and a RouteGate in the failover sweep. Failed failover attempts are not request-counted (known undercount; spec future work).
  • Counters are per instance and reset on restart: N replicas ≈ N× the configured limit. This is stated plainly in the docs (multi-instance drift was LiteLLM's loudest complaint for years); rule definitions persist in the rate_limits store on SQLite/Postgres/Mongo. Redis-backed counters, per-(path×model) matrix rules, and token pre-reservation are scoped as future work in the spec.
  • The SET_BUDGET_*/SET_RATE_LIMIT_*/SET_PROVIDER_RATE_LIMIT_* env appliers share a generic keyed merge helper.
  • Also fixes the rate-limit modal silently refusing to save concurrent rules: native form validation blocked submission on the hidden period-seconds input (min=1 vs value 0); the form is now novalidate and scope/period switches clear stale hidden fields.

Testing

  • Unit: sliding-window math, token post-accounting (incl. cache-hit exclusion and executed provider/model attribution), concurrency acquire/release idempotency, subject matching for all three scopes (bare vs qualified models, case folding), read-only RouteAvailable probe, failover skip via RouteGate, stores (round-trip, config-replace, manual-wins-over-config, pre-scope schema migration), config parsing/validation (provider env, underscore→hyphen suffixes), server enforcement (429 shape, headers, release-on-completion), admin CRUD/reset for all scopes.
  • Dashboard: 412 JS module tests passing (scope selector, payload building, sorting/filtering, legacy-item fallback).
  • E2E (tests/e2e/ratelimit_test.go): RPM blocking with header assertions, token-limit blocking through the real usage pipeline, concurrency slot hold/release, provider-scope counter shared across consumer paths, admin endpoints; blocked requests verified to never reach the upstream provider.
  • Manual smoke test of the real binary: live schema migration of pre-scope rules verified against a dev database, provider rule round-trip via /admin/rate-limits.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added configurable rate limits for user_path, provider, and model via YAML and environment variables, including request/token caps (token limits require usage tracking) and in-flight concurrency limits.
    • Introduced a “Rate Limits” admin dashboard page and REST endpoints to list, create/update, delete, and reset rules/counters.
    • Enforced OpenAI-style 429 responses with Retry-After and x-ratelimit-* headers.
  • Bug Fixes
    • Applied rate limiting consistently across chat, embeddings, audio, realtime, batch, and passthrough flows, including correct release and rate-limit-aware failover.
  • Documentation
    • Expanded configuration/behavior docs, including rule matching, window semantics, and admin workflows.

Add a rate limiting feature that caps how fast a user_path subtree can
consume the gateway, complementing budgets (spend control) with traffic
control:

- Rules cap max_requests and/or max_tokens per minute/hour/day/custom
  window, plus a "concurrent" period for in-flight request caps. One
  shared counter per rule covers its whole subtree; per-key limits fall
  out of binding each managed key to its own user_path.
- Enforcement runs before the budget check on every model endpoint
  (chat, responses, messages, embeddings, audio, passthrough, realtime
  sessions, batch submission). Requests use an in-memory sliding-window
  counter; tokens are post-accounted from usage entries via a usage
  logger tap (cache hits excluded, requires USAGE_ENABLED); realtime
  sessions hold their concurrency slot for the session lifetime.
- Breaches return 429 with an OpenAI-shaped error (code
  rate_limit_exceeded) and an accurate Retry-After; successes carry
  x-ratelimit-{limit,remaining,reset}-{requests,tokens} headers from
  the most-constrained matching rule.
- Managed via a new dashboard Rate Limits page, /admin/rate-limits
  CRUD + reset endpoints, a rate_limits: YAML block, and
  SET_RATE_LIMIT_<PATH> env vars (rpm/tpm/rph/tph/rpd/tpd/concurrent).
  Gated by RATE_LIMITS_ENABLED (default true; no-op without rules).
- Counters are per instance and reset on restart (documented); rule
  definitions persist in a new rate_limits store on all three backends.
- The SET_BUDGET_*/SET_RATE_LIMIT_* env appliers share a new generic
  per-user-path env merge helper instead of duplicating the loop.

Spec with gateway research (LiteLLM, Bifrost, Portkey, Kong,
Cloudflare, Helicone) in docs/dev/2026-07-05_rate-limiting-spec.md;
user docs in docs/features/rate-limits.mdx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mintlify

mintlify Bot commented Jul 5, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Jul 5, 2026, 9:14 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@SantiagoDePolonia, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2f7dec14-8bf6-4a42-a814-634d0529dfa3

📥 Commits

Reviewing files that changed from the base of the PR and between 9f8d24e and 9172aff.

📒 Files selected for processing (18)
  • CLAUDE.md
  • docs/dev/2026-07-05_rate-limiting-spec.md
  • docs/features/rate-limits.mdx
  • internal/admin/dashboard/static/js/dashboard.js
  • internal/admin/dashboard/static/js/modules/rate-limits.js
  • internal/admin/dashboard/static/js/modules/rate-limits.test.cjs
  • internal/admin/dashboard/templates/rate-limit-editor.html
  • internal/core/context.go
  • internal/gateway/failover.go
  • internal/gateway/failover_test.go
  • internal/gateway/inference_execute.go
  • internal/ratelimit/store_mongodb.go
  • internal/ratelimit/store_mongodb_test.go
  • internal/server/messages_handler.go
  • internal/server/passthrough_service.go
  • internal/server/ratelimit_support.go
  • internal/server/ratelimit_support_test.go
  • internal/server/translated_inference_service.go
📝 Walkthrough

Walkthrough

This PR adds configurable rate limiting across config, storage, request admission, admin management, dashboard UI, and documentation. It also adds end-to-end coverage for request, token, provider, concurrency, and admin rule flows.

Changes

Rate Limiting

Layer / File(s) Summary
Config and env parsing
config/ratelimit.go, config/user_path_env.go, config/budget.go, config/config.go, config/config.example.yaml, config/config_test.go, .env.template, CLAUDE.md
Adds the rate-limit config model, env/YAML parsing and validation, shared user-path env handling, example config, and config docs/tests.
Limiter, service, and persistence
internal/ratelimit/types*.go, internal/ratelimit/limiter.go, internal/ratelimit/service*.go, internal/ratelimit/usage_tap*.go, internal/ratelimit/store*.go, internal/ratelimit/factory.go
Adds the rate-limit domain types, sliding-window limiter, service orchestration, usage token accounting, and storage backends with tests.
App, server, admin, and dashboard wiring
internal/app/app.go, internal/app/ratelimit_catalog.go, internal/server/*ratelimit*, internal/server/*service.go, internal/server/http.go, internal/server/handlers.go, internal/gateway/*, internal/admin/*, internal/admin/dashboard/*, tests/e2e/setup_test.go
Wires rate limiting into app startup/shutdown, routing/failover gates, server request handling, admin routes and handlers, and the dashboard page/module.
Docs and end-to-end coverage
docs/dev/2026-07-05_rate-limiting-spec.md, docs/features/rate-limits.mdx, docs/docs.json, tests/e2e/ratelimit_test.go
Adds the rate-limiting specification, user-facing docs, navigation entry, and end-to-end coverage for request, token, provider, concurrency, and admin flows.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • ENTERPILOT/GoModel#247: Extends the same dashboard modal and overlay plumbing used by the new rate-limit editor and inspector pages.
  • ENTERPILOT/GoModel#292: Modifies config/budget.go, the same env-override path refactored here for keyed configuration parsing.
  • ENTERPILOT/GoModel#294: Touches the admin handler/routing surface that now also carries the rate-limit endpoints and error mapping.

Poem

A rabbit counted, hop by hop,
Through tokens, paths, and windows' top.
When limits yawned, the warnings sang,
Yet dashboards lit with gentle bang.
🐇⏱️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: scoped rate limiting for requests, tokens, and concurrency across user paths, providers, and models.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rate-limiting

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jul 5, 2026

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

Mostly safe to merge after fixing the translated-route failover ordering issue.

Core limiter, store, and admin behavior is covered by focused tests, but the current handler ordering breaks the advertised provider/model failover behavior for saturated primary routes.

internal/server/translated_inference_service.go

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex ran the requested verification for the pull request, but its local artifact references were not uploaded.
  • The RateLimit end-to-end test log confirms the test run details, including the exact command, working directory, start and end times, verbose output, request/response logs, and a PASS with EXIT_CODE: 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant Server as Server handler
participant RL as RateLimit Service
participant Orch as Inference Orchestrator
participant Gate as RouteGate
participant Provider
participant Usage as UsageTap

Client->>Server: Model request
Server->>RL: Acquire(user_path, provider, model)
alt user_path limit exceeded
    RL-->>Server: ExceededError
    Server-->>Client: 429 + Retry-After/x-ratelimit headers
else admitted
    RL-->>Server: Reservation + headers
    Server->>Orch: Execute/Stream request
    Orch->>Gate: RouteAvailable(failover target)
    Gate->>RL: Probe provider/model capacity
    Orch->>Provider: Dispatch to available target
    Provider-->>Orch: Response/stream
    Orch-->>Server: Result
    Server-->>Client: OpenAI-compatible response
    Server->>RL: Release concurrency slot
    Usage->>RL: RecordTokens(executed provider/model)
end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client
participant Server as Server handler
participant RL as RateLimit Service
participant Orch as Inference Orchestrator
participant Gate as RouteGate
participant Provider
participant Usage as UsageTap

Client->>Server: Model request
Server->>RL: Acquire(user_path, provider, model)
alt user_path limit exceeded
    RL-->>Server: ExceededError
    Server-->>Client: 429 + Retry-After/x-ratelimit headers
else admitted
    RL-->>Server: Reservation + headers
    Server->>Orch: Execute/Stream request
    Orch->>Gate: RouteAvailable(failover target)
    Gate->>RL: Probe provider/model capacity
    Orch->>Provider: Dispatch to available target
    Provider-->>Orch: Response/stream
    Orch-->>Server: Result
    Server-->>Client: OpenAI-compatible response
    Server->>RL: Release concurrency slot
    Usage->>RL: RecordTokens(executed provider/model)
end
Loading

Reviews (2): Last reviewed commit: "style(dashboard): add vertical padding t..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/app/app.go (1)

443-460: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Wire the guardrail executor through the tapped usage logger.

internal/app/app.go:575-584 still passes usageResult.Logger, so guardrail-triggered completions skip ratelimit.NewUsageTap and never update the token windows. Pass serverUsageLogger here too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/app/app.go` around lines 443 - 460, The guardrail executor is still
using the raw usage logger, so completions bypass the rate-limit usage tap and
token windows are not updated. Update the guardrail wiring in the app setup to
pass the existing serverUsageLogger instead of usageResult.Logger, keeping the
same tapped logger used for server requests. Use the surrounding
server.Config/server usage logger setup to locate the change.
internal/server/budget_support_test.go (1)

69-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for a non-nil rate limiter in batchAdmissionEnforcer.

Both tests pass nil for the new rate-limiter argument, so only the pre-existing budget-check behavior is verified. Consider adding a case with a real/fake RateLimiter to confirm batchAdmissionEnforcer actually enforces rate limits for batch submission (e.g., that an exceeded limit surfaces as an error before/along with the budget check).

Also applies to: 94-117

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/budget_support_test.go` around lines 69 - 92, Add test
coverage for the non-nil rate-limiter path in batchAdmissionEnforcer, since the
current batchAdmissionEnforcer and TestBatchBudgetEnforcerUsesResolvedWorkflow
only exercise the nil limiter case. Introduce a fake or stub RateLimiter in the
budget_support tests and verify that batchAdmissionEnforcer enforces batch
submission limits by returning an error when the limiter rejects the request,
while still keeping the existing budget-check assertions around
countingBudgetChecker and ResolvedWorkflowPolicy.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/ratelimit.go`:
- Around line 133-152: The period-to-seconds values in rateLimitEnvName are
duplicated and should be shared with rateLimitConfigPeriodSeconds to avoid
keeping two switch statements in sync. Extract common constants for 60, 3600,
and 86400, then update both rateLimitEnvName and rateLimitConfigPeriodSeconds to
use those shared values while preserving the existing rpm/tpm/rph/tph/rpd/tpd
and concurrent/concurrency mappings.

In `@internal/admin/dashboard/static/js/modules/rate-limits.js`:
- Around line 254-288: The rate limit payload builder accepts a blank custom
period seconds value because rateLimitFormPayload() coerces an empty string to
0, which can accidentally submit a concurrent rule. Update
rateLimitFormPayload() to explicitly reject empty or missing period_seconds
before calling Number(), and only allow 0 when the period is truly concurrent;
keep the existing validation and payload construction keyed off rateLimitForm,
period, and limit_key.

In `@internal/admin/dashboard/static/js/modules/rate-limits.test.cjs`:
- Around line 122-140: The `fetchRateLimits handles 503 as feature unavailable`
test has a redundant and misleading `global.fetch` override because
`createRateLimitsModule(...)` already injects `fetch` into the vm context used
by `fetchRateLimits()`. Remove the `global.fetch` setup/cleanup and update the
comment so the test clearly relies on the injected `fetch` from
`loadRateLimitsModuleFactory`/`createRateLimitsModule`, keeping the test focused
on the module’s own fetch handling.

In `@internal/ratelimit/factory.go`:
- Around line 106-137: Config-seeded rate limit rules bypass the same
normalization and validation used by admin-created rules. Update
seedConfiguredRules to build each Rule through NormalizeRule after
NormalizeUserPath, so CreatedAt/UpdatedAt are populated and cross-field checks
like period/max_tokens/max_requests are enforced consistently with
UpsertRateLimit and other rule creation paths.

In `@internal/ratelimit/limiter.go`:
- Around line 47-53: The current `windowCounter.resetAfter` only computes the
fixed bucket rollover, so `ExceededError.RetryAfter` can be too short for the
sliding-window estimate. Add a helper in `limiter.go` that calculates the
earliest recovery time when the weighted current/previous windows drop back
below the limit, and use it when setting `RetryAfter` in the request/token
exceed paths. Update the `windowCounter`/limiter logic to base recovery on the
sliding estimate rather than the bucket boundary, and add tests covering the
rate-limit behavior change for the affected request and token cases.

In `@internal/ratelimit/service.go`:
- Around line 76-92: The `DeleteRule` path is duplicating the same
`period_seconds` validation and error text that also exists in the store
implementations, so the logic can drift. Extract a shared helper like
`validatePeriodSeconds(int64) error` in the ratelimit store layer near
`normalizeRulesForUpsert`, then update `Service.DeleteRule` and the
store-specific `DeleteRule` methods (including `store_sqlite.go` and the other
backend implementations) to call it instead of inlining the check. Keep the
existing error message centralized in that helper so all callers stay
consistent.

In `@internal/ratelimit/store_mongodb.go`:
- Around line 72-85: The MongoDB upsert path in upsertNormalizedRules should be
source-aware so seeded config does not overwrite manual edits that appear after
configRulesWithoutManualCollisions runs. Update the write logic in
store_mongodb.go to either add a source-precedence condition to the
UpdateOneModel filter/update or treat manual-collision duplicates as benign
skips, and make the same change in the related bulk upsert path referenced by
the other affected block.

In `@internal/server/ratelimit_support.go`:
- Around line 58-73: The fallback in rateLimitCheckError is mapping
non-ExceededError failures from Acquire to a provider 503, but those cases are
malformed user_path path-normalization errors and should be treated as invalid
requests. Update rateLimitCheckError to detect this specific failure path and
return an invalid-request/400 style core error instead of
rate_limit_check_failed, while keeping the existing exceeded branch and
rateLimitBreachHeaders handling intact.

In `@tests/e2e/ratelimit_test.go`:
- Around line 1-194: Add an e2e test that covers concurrency-based rate limiting
using the existing rate limit test helpers and the ratelimit service setup.
Create a rule with a concurrency scope/MaxConcurrent limit and verify
overlapping in-flight requests: the first request should be admitted and held
open, the second should receive the rate-limit response and headers, and the
blocked request must not reach the upstream mockServer. Use the existing
TestRateLimitRequestEnforcement_E2E and TestRateLimitTokenEnforcement_E2E
patterns plus symbols like setupRateLimitService, setupE2EServer, and
rateLimitInt64 to locate the right test area.

---

Outside diff comments:
In `@internal/app/app.go`:
- Around line 443-460: The guardrail executor is still using the raw usage
logger, so completions bypass the rate-limit usage tap and token windows are not
updated. Update the guardrail wiring in the app setup to pass the existing
serverUsageLogger instead of usageResult.Logger, keeping the same tapped logger
used for server requests. Use the surrounding server.Config/server usage logger
setup to locate the change.

In `@internal/server/budget_support_test.go`:
- Around line 69-92: Add test coverage for the non-nil rate-limiter path in
batchAdmissionEnforcer, since the current batchAdmissionEnforcer and
TestBatchBudgetEnforcerUsesResolvedWorkflow only exercise the nil limiter case.
Introduce a fake or stub RateLimiter in the budget_support tests and verify that
batchAdmissionEnforcer enforces batch submission limits by returning an error
when the limiter rejects the request, while still keeping the existing
budget-check assertions around countingBudgetChecker and ResolvedWorkflowPolicy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 76458e31-9dce-4ca5-9aff-e59d34ec8c0a

📥 Commits

Reviewing files that changed from the base of the PR and between 72246d5 and 27b3359.

📒 Files selected for processing (53)
  • .env.template
  • CLAUDE.md
  • config/budget.go
  • config/config.example.yaml
  • config/config.go
  • config/config_test.go
  • config/ratelimit.go
  • config/ratelimit_test.go
  • config/user_path_env.go
  • docs/dev/2026-07-05_rate-limiting-spec.md
  • docs/docs.json
  • docs/features/rate-limits.mdx
  • internal/admin/dashboard/static/js/dashboard.js
  • internal/admin/dashboard/static/js/modules/rate-limits.js
  • internal/admin/dashboard/static/js/modules/rate-limits.test.cjs
  • internal/admin/dashboard/templates/index.html
  • internal/admin/dashboard/templates/layout.html
  • internal/admin/dashboard/templates/page-rate-limits.html
  • internal/admin/dashboard/templates/sidebar.html
  • internal/admin/errors.go
  • internal/admin/handler.go
  • internal/admin/handler_budgets.go
  • internal/admin/handler_ratelimits.go
  • internal/admin/handler_ratelimits_test.go
  • internal/admin/routes.go
  • internal/admin/routes_test.go
  • internal/app/app.go
  • internal/ratelimit/factory.go
  • internal/ratelimit/limiter.go
  • internal/ratelimit/service.go
  • internal/ratelimit/service_test.go
  • internal/ratelimit/store.go
  • internal/ratelimit/store_mongodb.go
  • internal/ratelimit/store_postgresql.go
  • internal/ratelimit/store_sqlite.go
  • internal/ratelimit/store_sqlite_test.go
  • internal/ratelimit/types.go
  • internal/ratelimit/types_test.go
  • internal/ratelimit/usage_tap.go
  • internal/ratelimit/usage_tap_test.go
  • internal/server/audio_service.go
  • internal/server/budget_support_test.go
  • internal/server/handlers.go
  • internal/server/http.go
  • internal/server/messages_handler.go
  • internal/server/native_batch_service.go
  • internal/server/passthrough_service.go
  • internal/server/ratelimit_support.go
  • internal/server/ratelimit_support_test.go
  • internal/server/realtime_service.go
  • internal/server/translated_inference_service.go
  • tests/e2e/ratelimit_test.go
  • tests/e2e/setup_test.go

Comment thread config/ratelimit.go
Comment thread internal/admin/dashboard/static/js/modules/rate-limits.js
Comment thread internal/admin/dashboard/static/js/modules/rate-limits.test.cjs
Comment on lines +106 to +137
func seedConfiguredRules(ctx context.Context, service *Service, cfg config.RateLimitsConfig) error {
if service == nil {
return nil
}
rules := make([]Rule, 0)
for _, entry := range cfg.UserPaths {
userPath, err := NormalizeUserPath(entry.Path)
if err != nil {
return fmt.Errorf("invalid rate limit user path %q: %w", entry.Path, err)
}
for limitIdx, limit := range entry.Limits {
var seconds int64
if limit.PeriodSeconds != nil {
seconds = *limit.PeriodSeconds
} else {
parsed, ok := PeriodSecondsFromName(limit.Period)
if !ok {
return fmt.Errorf("invalid rate limit period for user path %q limit %d: %q", userPath, limitIdx, limit.Period)
}
seconds = parsed
}
rules = append(rules, Rule{
UserPath: userPath,
PeriodSeconds: seconds,
MaxRequests: limit.MaxRequests,
MaxTokens: limit.MaxTokens,
Source: SourceConfig,
})
}
}
return service.ReplaceConfigRules(ctx, rules)
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Config-seeded rules skip NormalizeRule, unlike manual admin rules.

seedConfiguredRules only calls NormalizeUserPath and builds Rule{...} directly, leaving CreatedAt/UpdatedAt as zero-value time.Time and skipping the cross-field validation NormalizeRule performs (e.g. rejecting max_tokens on a concurrent period, or requiring at least one of max_requests/max_tokens). The admin API path (UpsertRateLimit in internal/admin/handler_ratelimits.go) calls ratelimit.NormalizeRule for the same purpose, so YAML/env-seeded rules and manually-created rules take different, inconsistent validation/normalization paths.

🔧 Proposed fix
 		for limitIdx, limit := range entry.Limits {
 			var seconds int64
 			if limit.PeriodSeconds != nil {
 				seconds = *limit.PeriodSeconds
 			} else {
 				parsed, ok := PeriodSecondsFromName(limit.Period)
 				if !ok {
 					return fmt.Errorf("invalid rate limit period for user path %q limit %d: %q", userPath, limitIdx, limit.Period)
 				}
 				seconds = parsed
 			}
-			rules = append(rules, Rule{
-				UserPath:      userPath,
-				PeriodSeconds: seconds,
-				MaxRequests:   limit.MaxRequests,
-				MaxTokens:     limit.MaxTokens,
-				Source:        SourceConfig,
-			})
+			rule, err := NormalizeRule(Rule{
+				UserPath:      userPath,
+				PeriodSeconds: seconds,
+				MaxRequests:   limit.MaxRequests,
+				MaxTokens:     limit.MaxTokens,
+				Source:        SourceConfig,
+			})
+			if err != nil {
+				return fmt.Errorf("invalid rate limit rule for user path %q limit %d: %w", userPath, limitIdx, err)
+			}
+			rules = append(rules, rule)
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func seedConfiguredRules(ctx context.Context, service *Service, cfg config.RateLimitsConfig) error {
if service == nil {
return nil
}
rules := make([]Rule, 0)
for _, entry := range cfg.UserPaths {
userPath, err := NormalizeUserPath(entry.Path)
if err != nil {
return fmt.Errorf("invalid rate limit user path %q: %w", entry.Path, err)
}
for limitIdx, limit := range entry.Limits {
var seconds int64
if limit.PeriodSeconds != nil {
seconds = *limit.PeriodSeconds
} else {
parsed, ok := PeriodSecondsFromName(limit.Period)
if !ok {
return fmt.Errorf("invalid rate limit period for user path %q limit %d: %q", userPath, limitIdx, limit.Period)
}
seconds = parsed
}
rules = append(rules, Rule{
UserPath: userPath,
PeriodSeconds: seconds,
MaxRequests: limit.MaxRequests,
MaxTokens: limit.MaxTokens,
Source: SourceConfig,
})
}
}
return service.ReplaceConfigRules(ctx, rules)
}
func seedConfiguredRules(ctx context.Context, service *Service, cfg config.RateLimitsConfig) error {
if service == nil {
return nil
}
rules := make([]Rule, 0)
for _, entry := range cfg.UserPaths {
userPath, err := NormalizeUserPath(entry.Path)
if err != nil {
return fmt.Errorf("invalid rate limit user path %q: %w", entry.Path, err)
}
for limitIdx, limit := range entry.Limits {
var seconds int64
if limit.PeriodSeconds != nil {
seconds = *limit.PeriodSeconds
} else {
parsed, ok := PeriodSecondsFromName(limit.Period)
if !ok {
return fmt.Errorf("invalid rate limit period for user path %q limit %d: %q", userPath, limitIdx, limit.Period)
}
seconds = parsed
}
rule, err := NormalizeRule(Rule{
UserPath: userPath,
PeriodSeconds: seconds,
MaxRequests: limit.MaxRequests,
MaxTokens: limit.MaxTokens,
Source: SourceConfig,
})
if err != nil {
return fmt.Errorf("invalid rate limit rule for user path %q limit %d: %w", userPath, limitIdx, err)
}
rules = append(rules, rule)
}
}
return service.ReplaceConfigRules(ctx, rules)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ratelimit/factory.go` around lines 106 - 137, Config-seeded rate
limit rules bypass the same normalization and validation used by admin-created
rules. Update seedConfiguredRules to build each Rule through NormalizeRule after
NormalizeUserPath, so CreatedAt/UpdatedAt are populated and cross-field checks
like period/max_tokens/max_requests are enforced consistently with
UpsertRateLimit and other rule creation paths.

Comment thread internal/ratelimit/limiter.go
Comment thread internal/ratelimit/service.go Outdated
Comment on lines +76 to +92
func (s *Service) DeleteRule(ctx context.Context, userPath string, periodSeconds int64) error {
if s == nil || s.store == nil {
return ErrUnavailable
}
userPath, err := NormalizeUserPath(userPath)
if err != nil {
return err
}
if periodSeconds < 0 {
return fmt.Errorf("period_seconds must be 0 (concurrent) or greater")
}
if err := s.store.DeleteRule(ctx, userPath, periodSeconds); err != nil {
return err
}
s.limiter.reset(ruleKey{userPath: userPath, periodSeconds: periodSeconds})
return s.Refresh(ctx)
}

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate period_seconds validation across layers.

The period_seconds must be 0 (concurrent) or greater check (and message) here is duplicated verbatim in internal/ratelimit/store_sqlite.go's DeleteRule (lines 93-95), and likely also in the PostgreSQL/MongoDB store implementations following the same pattern. Extracting a shared validatePeriodSeconds(int64) error helper (e.g., in store.go alongside normalizeRulesForUpsert) would prevent the message/logic from drifting if one call site is updated without the others.

♻️ Proposed extraction
+func validatePeriodSeconds(periodSeconds int64) error {
+	if periodSeconds < 0 {
+		return fmt.Errorf("period_seconds must be 0 (concurrent) or greater")
+	}
+	return nil
+}
+
 func (s *Service) DeleteRule(ctx context.Context, userPath string, periodSeconds int64) error {
 	if s == nil || s.store == nil {
 		return ErrUnavailable
 	}
 	userPath, err := NormalizeUserPath(userPath)
 	if err != nil {
 		return err
 	}
-	if periodSeconds < 0 {
-		return fmt.Errorf("period_seconds must be 0 (concurrent) or greater")
-	}
+	if err := validatePeriodSeconds(periodSeconds); err != nil {
+		return err
+	}
 	if err := s.store.DeleteRule(ctx, userPath, periodSeconds); err != nil {
 		return err
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ratelimit/service.go` around lines 76 - 92, The `DeleteRule` path is
duplicating the same `period_seconds` validation and error text that also exists
in the store implementations, so the logic can drift. Extract a shared helper
like `validatePeriodSeconds(int64) error` in the ratelimit store layer near
`normalizeRulesForUpsert`, then update `Service.DeleteRule` and the
store-specific `DeleteRule` methods (including `store_sqlite.go` and the other
backend implementations) to call it instead of inlining the check. Keep the
existing error message centralized in that helper so all callers stay
consistent.

Comment thread internal/ratelimit/store_mongodb.go
Comment on lines +58 to +73
func rateLimitCheckError(err error) error {
var exceeded *ratelimit.ExceededError
if errors.As(err, &exceeded) {
message := exceeded.Error()
if message == "" {
message = "rate limit exceeded"
}
gatewayErr := core.NewRateLimitError("ratelimit", message).WithCode("rate_limit_exceeded")
return &gatewayErrorWithResponseHeaders{
GatewayError: gatewayErr,
headers: rateLimitBreachHeaders(exceeded),
}
}
return core.NewProviderError("ratelimit", http.StatusServiceUnavailable, "rate limit check failed", err).
WithCode("rate_limit_check_failed")
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect NormalizeUserPath and Service.matchingRules/admit to see what error types Acquire can surface.
fd -e go . internal/ratelimit | xargs rg -n "func NormalizeUserPath|func \(s \*Service\) matchingRules|func \(l \*.*\) admit" -A 15

Repository: ENTERPILOT/GoModel

Length of output: 2666


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the rate-limit service flow and the core path normalization error taxonomy.
sed -n '1,220p' internal/ratelimit/service.go
printf '\n----\n'
sed -n '1,220p' internal/ratelimit/types.go
printf '\n----\n'
fd -e go . internal/core | xargs rg -n "func NormalizeUserPath|type .*Invalid.*Path|Invalid.*Path|NormalizeUserPath" -A 20

Repository: ENTERPILOT/GoModel

Length of output: 18898


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the gateway-side handling of Acquire errors and any tests that encode the intended status mapping.
fd -e go . internal | xargs rg -n "rateLimitCheckError|rate_limit_check_failed|rate_limit_exceeded|Acquire\(" -A 20

Repository: ENTERPILOT/GoModel

Length of output: 37499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for tests or callers that distinguish invalid user paths from backend rate-limit failures.
fd -e go . . | xargs rg -n "InvalidRequest|ServiceUnavailable|rate_limit_check_failed|NormalizeUserPath\(" -A 10

Repository: ENTERPILOT/GoModel

Length of output: 50375


Map invalid user_path errors to 400 (internal/server/ratelimit_support.go:58-73)

Acquire only returns a non-ExceededError on path-normalization failures, so this fallback turns malformed user_path values into 503 rate_limit_check_failed. That should be an invalid-request response instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/ratelimit_support.go` around lines 58 - 73, The fallback in
rateLimitCheckError is mapping non-ExceededError failures from Acquire to a
provider 503, but those cases are malformed user_path path-normalization errors
and should be treated as invalid requests. Update rateLimitCheckError to detect
this specific failure path and return an invalid-request/400 style core error
instead of rate_limit_check_failed, while keeping the existing exceeded branch
and rateLimitBreachHeaders handling intact.

Comment thread tests/e2e/ratelimit_test.go
- Retry-After now reports the earliest second the sliding-window
  estimate would actually admit a retry, instead of the next bucket
  boundary, which under-promised right after a rollover while the
  previous window still weighed in (docs updated to match).
- The Mongo store's upsert is source-aware like the SQL stores: a
  config-sourced write only updates config rows, and the duplicate-key
  insert produced by a shadowing manual row is treated as the intended
  precedence, not a failure.
- Guardrail LLM calls run through the tapped usage logger so their
  tokens count toward rate limit token windows.
- The dashboard form rejects blank custom period seconds instead of
  coercing them to 0 and silently submitting a concurrent rule.
- Deduped the period_seconds validation shared by DeleteRule and the
  stores, and the period constants shared by the env-name and YAML
  period parsers.
- Added an e2e concurrency test (held in-flight request blocks the
  next one, slot frees on completion), a batch-enforcer test with a
  live limiter, exact Retry-After recovery tests, and unit coverage
  for the Mongo duplicate-key classification; removed a redundant
  global.fetch override from the dashboard module test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rule identity becomes (scope, subject, period). user_path rules keep their
admission-control behavior (429 + Retry-After + x-ratelimit-* headers).
Provider and model rules act as routing constraints: virtual-model load
balancing consults a rate-limit-aware catalog and failover sweeps consult a
RouteGate, so saturated targets are skipped like stale-inventory providers
and clients only see 429 when no viable target remains. Token windows are
charged to the executed provider/model from usage entries, keeping accounting
correct under aliasing and failover.

Config gains rate_limits.providers / rate_limits.models blocks and
SET_PROVIDER_RATE_LIMIT_<NAME> env vars (suffix underscores map to hyphens
like provider-instance env vars; model rules are YAML/admin-only). The
dashboard form gets a scope selector and rows show scope chips. Pre-scope
rate_limits tables/documents migrate in place at store init on SQLite,
PostgreSQL, and MongoDB.

Also fixes the rate-limit modal silently refusing to save concurrent rules:
native form validation blocked submission on the hidden period-seconds input
(min=1 vs value 0); the form is now novalidate and scope/period switches
clear stale hidden fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SantiagoDePolonia SantiagoDePolonia changed the title feat(ratelimit): per-user-path request, token, and concurrency limits feat(ratelimit): scoped request, token, and concurrency limits (user paths, providers, models) Jul 5, 2026

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
internal/ratelimit/service.go (1)

192-210: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

RecordTokens silently drops token accounting on normalization failure.

If normalizeSubjects errors (malformed UserPath), the function returns without recording tokens and without any log, which can silently hide accounting bugs and let token limits under-count for that subject.

🔍 Suggested fix
 	subjects, err := normalizeSubjects(subjects)
 	if err != nil {
+		slog.Debug("ratelimit: dropping token record due to invalid subjects", "error", err)
 		return
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ratelimit/service.go` around lines 192 - 210, RecordTokens is
swallowing normalizeSubjects errors and silently skipping accounting, which can
hide malformed UserPath issues. Update Service.RecordTokens to handle the
normalizeSubjects(subjects) error explicitly by logging or otherwise surfacing
it before returning, using the RecordTokens and normalizeSubjects paths to
locate the fix, while keeping the existing early-return behavior for invalid
inputs.
internal/admin/dashboard/static/js/modules/rate-limits.js (1)

326-356: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Blank user_path subject silently becomes root scope (/) with no validation error.

Provider/model scopes reject a blank subject (Line 330-332), but user_path scope has no equivalent guard: subject || '/' (Line 354) silently converts a cleared/whitespace-only path into a root-level rule that "limits the whole subtree" (per the template help text). Clearing the field while editing an existing narrower rule (e.g. /team/alpha) and saving would silently broaden it to affect all traffic, without any warning.

🐛 Proposed fix
                 const isConcurrent = String(form.period || '') === 'concurrent';
+                if (scope === 'user_path' && !subject) {
+                    return { error: 'User path is required.' };
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/js/modules/rate-limits.js` around lines 326 -
356, The rate limit payload builder in rateLimitFormPayload() silently converts
an empty user_path subject into “/” via subject || '/', which can broaden an
existing path rule without validation. Add an explicit validation for user_path
that rejects blank or whitespace-only subjects before building the payload, and
only allow a normalized root subject when the user intentionally sets it. Keep
the existing scope/subject checks in rateLimitFormPayload() and use the same
error-reporting pattern as the other required-field validation paths.
internal/ratelimit/store_mongodb.go (1)

90-129: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Keep duplicate-key retries out of the manual write path

Config seeds can keep the benign duplicate-key skip, but manual upserts can race on a new key and currently return success after one writer’s values are dropped. Retry that conflict as a normal update, or surface it instead of swallowing every duplicate-key BulkWrite error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ratelimit/store_mongodb.go` around lines 90 - 129, The duplicate-key
handling in MongoDBStore.upsertNormalizedRules is too broad and masks manual
write races. Keep the benign skip only for config-seeded writes (SourceConfig
filter path), and for manual upserts either retry the conflicting BulkWrite as a
normal update or return the error instead of treating every duplicate-key error
as success. Use upsertNormalizedRules, isOnlyDuplicateKeyErrors, and the
SourceConfig branch to localize the fix.
internal/server/translated_inference_service.go (1)

99-103: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the repeated rate-limit + budget enforcement pattern.

The enforceRateLimitdefer release()enforceBudget sequence is duplicated verbatim across dispatchChatCompletion, dispatchResponses, and Embeddings in this file (and again in passthrough_service.go). Any future change to enforcement ordering (e.g. adding a guardrail check between rate limit and budget) needs to be applied consistently in 4 places.

♻️ Suggested consolidation
// in ratelimit_support.go
func enforceRateLimitAndBudget(c *echo.Context, limiter RateLimiter, checker BudgetChecker, route rateLimitRoute) (func(), error) {
	release, err := enforceRateLimit(c, limiter, route)
	if err != nil {
		return func() {}, err
	}
	if err := enforceBudget(c, checker); err != nil {
		release()
		return func() {}, err
	}
	return release, nil
}
-	release, err := enforceRateLimit(c, s.rateLimiter, rateLimitRouteFromWorkflow(workflow))
-	if err != nil {
-		return handleError(c, err)
-	}
-	defer release()
-	if err := enforceBudget(c, s.budgetChecker); err != nil {
-		return handleError(c, err)
-	}
+	release, err := enforceRateLimitAndBudget(c, s.rateLimiter, s.budgetChecker, rateLimitRouteFromWorkflow(workflow))
+	if err != nil {
+		return handleError(c, err)
+	}
+	defer release()

Also applies to: 268-272, 458-462

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/translated_inference_service.go` around lines 99 - 103, The
rate-limit and budget enforcement sequence is duplicated in
dispatchChatCompletion, dispatchResponses, and Embeddings, so consolidate it
into a shared helper (for example, in ratelimit_support.go) and use that helper
from these dispatch paths. Keep the existing ordering and cleanup behavior by
having the helper wrap enforceRateLimit, defer release handling, and
enforceBudget in one place, then update the repeated call sites in
translated_inference_service.go (and the matching passthrough_service.go usage)
to use the new shared function.
internal/ratelimit/limiter.go (1)

123-162: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Return the longest blocking RetryAfter across all exceeded rules.

admit exits on the first exceeded rule, so overlapping exhausted windows can understate Retry-After (for example, minute + day limits where the minute rule is checked first). Continue evaluating matching rules, keep the exceeded error with the longest wait, and only skip the commit phase after all checks finish. As per coding guidelines, add or update tests for behavior changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ratelimit/limiter.go` around lines 123 - 162, The admit path in
limiter.go currently returns on the first exceeded rule, which can understate
Retry-After when multiple matching rules are exhausted. Update the admit logic
to keep evaluating all applicable rules in the loop, track the exceeded result
with the longest RetryAfter, and defer returning until after all checks
complete; use the existing rule/key/counter flow in admit, keyForRule, and
ExceededError. Ensure the commit phase is skipped only after evaluation finishes
and preserve the most specific exceeded scope/details from the chosen error. Add
or update tests covering overlapping rules where a longer RetryAfter should win
over an earlier shorter one.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/admin/handler_ratelimits_test.go`:
- Around line 251-328: Add a mixed-case model subject case to
TestRateLimitEndpointsProviderAndModelScopes so it verifies model subject
normalization/matching, not just provider normalization. Update the model upsert
path in UpsertRateLimit/DeleteRateLimit coverage to use a mixed-case subject
such as OpenAI/GPT-4o, then assert the stored response is normalized and that
Acquire in the service still matches a lowercase live request through
ratelimit.Subjects.

In `@internal/admin/handler_ratelimits.go`:
- Around line 266-293: The rateLimitRequestKey validation currently only rejects
a user_path when subject is missing, but it silently ignores user_path when both
subject and a non-user_path scope are provided. Update rateLimitRequestKey to
explicitly validate the rawSubject/rawUserPath combination before
NormalizeSubject: if scope is not ratelimit.ScopeUserPath and rawUserPath is
set, return an error instead of discarding it. Keep the existing scope,
NormalizeScope, NormalizeSubject, and rateLimitRequestPeriodSeconds flow intact,
and make the new error path consistent with the existing limit_key and subject
validation.

In `@internal/gateway/failover_test.go`:
- Around line 84-119: Add a matching test for tryFailoverStream to cover the
same routeGate skip behavior exercised by
TestTryFailoverResponseSkipsRateLimitedTargets. Reuse the existing
failoverTestFixture, blockingRouteGate, and stubFailoverResolver setup to make
one candidate rate-limited and verify tryFailoverStream skips it and proceeds to
the next available selector. Ensure the assertions check the attempted model
list and the successful failover result so the stream path stays aligned with
tryFailoverResponse.

In `@internal/ratelimit/store_postgresql.go`:
- Around line 42-95: Wrap migratePostgreSQLPreScopeTable in a single PostgreSQL
transaction so the RENAME, CREATE, INSERT...SELECT, and DROP steps are atomic
and cannot leave rate_limits partially migrated. Use the same
pool.Begin/tx.Commit pattern already used by ReplaceConfigRules, and run each
statement through the transaction instead of pool.Exec. Also consider taking an
advisory lock around the migration path to prevent multiple replicas from racing
while NewPostgreSQLStore probes and initializes the schema.

In `@internal/ratelimit/store_sqlite_test.go`:
- Around line 182-186: The in-memory SQLite test database is connection-local,
so the test setup can accidentally use a different empty database on later
queries. Update the SQLite test setup around sql.Open in store_sqlite_test.go
and the newSQLiteTestStore helper to call db.SetMaxOpenConns(1) after opening
the database, so all operations stay pinned to the same connection.

In `@internal/ratelimit/store_sqlite.go`:
- Around line 73-95: The rate_limits schema rebuild in StoreSQLite migration is
executed statement-by-statement without atomicity, so a failure in the middle
can leave a partially migrated database. Update the migration loop in
store_sqlite.go to run the rename/create/copy/drop sequence inside a single
transaction, and only commit after every statement succeeds; if any stmt
execution fails, rollback and return the wrapped error from the migration path.

In `@internal/ratelimit/types.go`:
- Around line 213-221: The model subject normalization in the ScopeModel branch
of the subject normalization logic should canonicalize case to match
modelSubjectMatches. Update the trimming/validation path so the returned subject
is lowercased after whitespace is removed and before storage, while keeping the
existing empty and slash checks intact. Use the existing normalize subject
handling around ScopeModel and modelSubjectMatches to ensure persisted model
rules are stored consistently and avoid duplicate entries with different casing.

---

Outside diff comments:
In `@internal/admin/dashboard/static/js/modules/rate-limits.js`:
- Around line 326-356: The rate limit payload builder in rateLimitFormPayload()
silently converts an empty user_path subject into “/” via subject || '/', which
can broaden an existing path rule without validation. Add an explicit validation
for user_path that rejects blank or whitespace-only subjects before building the
payload, and only allow a normalized root subject when the user intentionally
sets it. Keep the existing scope/subject checks in rateLimitFormPayload() and
use the same error-reporting pattern as the other required-field validation
paths.

In `@internal/ratelimit/limiter.go`:
- Around line 123-162: The admit path in limiter.go currently returns on the
first exceeded rule, which can understate Retry-After when multiple matching
rules are exhausted. Update the admit logic to keep evaluating all applicable
rules in the loop, track the exceeded result with the longest RetryAfter, and
defer returning until after all checks complete; use the existing
rule/key/counter flow in admit, keyForRule, and ExceededError. Ensure the commit
phase is skipped only after evaluation finishes and preserve the most specific
exceeded scope/details from the chosen error. Add or update tests covering
overlapping rules where a longer RetryAfter should win over an earlier shorter
one.

In `@internal/ratelimit/service.go`:
- Around line 192-210: RecordTokens is swallowing normalizeSubjects errors and
silently skipping accounting, which can hide malformed UserPath issues. Update
Service.RecordTokens to handle the normalizeSubjects(subjects) error explicitly
by logging or otherwise surfacing it before returning, using the RecordTokens
and normalizeSubjects paths to locate the fix, while keeping the existing
early-return behavior for invalid inputs.

In `@internal/ratelimit/store_mongodb.go`:
- Around line 90-129: The duplicate-key handling in
MongoDBStore.upsertNormalizedRules is too broad and masks manual write races.
Keep the benign skip only for config-seeded writes (SourceConfig filter path),
and for manual upserts either retry the conflicting BulkWrite as a normal update
or return the error instead of treating every duplicate-key error as success.
Use upsertNormalizedRules, isOnlyDuplicateKeyErrors, and the SourceConfig branch
to localize the fix.

In `@internal/server/translated_inference_service.go`:
- Around line 99-103: The rate-limit and budget enforcement sequence is
duplicated in dispatchChatCompletion, dispatchResponses, and Embeddings, so
consolidate it into a shared helper (for example, in ratelimit_support.go) and
use that helper from these dispatch paths. Keep the existing ordering and
cleanup behavior by having the helper wrap enforceRateLimit, defer release
handling, and enforceBudget in one place, then update the repeated call sites in
translated_inference_service.go (and the matching passthrough_service.go usage)
to use the new shared function.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 890e55d2-27e7-469c-b04d-79da04ee6f6d

📥 Commits

Reviewing files that changed from the base of the PR and between 8a38dc5 and a834ce9.

📒 Files selected for processing (42)
  • .env.template
  • CLAUDE.md
  • config/config.example.yaml
  • config/config_test.go
  • config/ratelimit.go
  • config/ratelimit_test.go
  • config/user_path_env.go
  • docs/dev/2026-07-05_rate-limiting-spec.md
  • docs/features/rate-limits.mdx
  • internal/admin/dashboard/static/js/modules/rate-limits.js
  • internal/admin/dashboard/static/js/modules/rate-limits.test.cjs
  • internal/admin/dashboard/templates/page-rate-limits.html
  • internal/admin/handler_ratelimits.go
  • internal/admin/handler_ratelimits_test.go
  • internal/app/app.go
  • internal/app/ratelimit_catalog.go
  • internal/gateway/failover.go
  • internal/gateway/failover_test.go
  • internal/gateway/inference_orchestrator.go
  • internal/ratelimit/factory.go
  • internal/ratelimit/limiter.go
  • internal/ratelimit/service.go
  • internal/ratelimit/service_test.go
  • internal/ratelimit/store.go
  • internal/ratelimit/store_mongodb.go
  • internal/ratelimit/store_mongodb_test.go
  • internal/ratelimit/store_postgresql.go
  • internal/ratelimit/store_sqlite.go
  • internal/ratelimit/store_sqlite_test.go
  • internal/ratelimit/types.go
  • internal/ratelimit/types_test.go
  • internal/ratelimit/usage_tap.go
  • internal/ratelimit/usage_tap_test.go
  • internal/server/audio_service.go
  • internal/server/budget_support_test.go
  • internal/server/messages_handler.go
  • internal/server/passthrough_service.go
  • internal/server/ratelimit_support.go
  • internal/server/ratelimit_support_test.go
  • internal/server/realtime_service.go
  • internal/server/translated_inference_service.go
  • tests/e2e/ratelimit_test.go

Comment thread internal/admin/handler_ratelimits_test.go
Comment thread internal/admin/handler_ratelimits.go
Comment thread internal/gateway/failover_test.go
Comment thread internal/ratelimit/store_postgresql.go
Comment thread internal/ratelimit/store_sqlite_test.go
Comment thread internal/ratelimit/store_sqlite.go
Comment thread internal/ratelimit/types.go
- Run the pre-scope schema migrations in one transaction on SQLite and
  PostgreSQL: a crash mid-rebuild could leave the table renamed away, and
  the next startup would create a fresh empty rate_limits and orphan every
  rule. The table DDL is now a single shared constant per store.
- Lowercase model rule subjects at normalization so storage matches the
  case-insensitive matching (no duplicate OpenAI/GPT-4o vs openai/gpt-4o
  rules covering the same requests).
- Report the exceeded rule with the longest recovery when several windows
  are exhausted at once, keeping Retry-After honest in one round trip; the
  breach check is now shared between admission and the read-only route
  probe.
- Restrict Mongo's benign duplicate-key skip to config-sourced writes and
  retry manual insert races as plain updates (last write wins, matching
  the SQL stores).
- Reject conflicting subject + user_path on provider/model admin requests
  instead of silently dropping the user_path.
- Pin in-memory SQLite test databases to one connection, cover the stream
  failover route-gate skip, and extract the shared rate-limit + budget
  admission sequence used by five dispatch sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/ratelimit/store_mongodb.go`:
- Around line 130-138: The retry-failure path in the rate limit MongoDB upsert
flow is returning the original duplicate-key error instead of the retry’s actual
failure. In the logic around s.rules.BulkWrite, capture the retry error from the
second BulkWrite attempt and, when it is not an allowed duplicate-key-only case,
wrap and return that retryErr rather than err so the real cause is preserved.
Keep the duplicate-key fast path unchanged, but make the fall-through report the
final failure from the retry.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 211ed07a-1401-4778-bc8d-72d3e9f41a1e

📥 Commits

Reviewing files that changed from the base of the PR and between a834ce9 and 88bff16.

📒 Files selected for processing (17)
  • internal/admin/dashboard/static/js/modules/rate-limits.js
  • internal/admin/handler_ratelimits.go
  • internal/admin/handler_ratelimits_test.go
  • internal/gateway/failover_test.go
  • internal/ratelimit/limiter.go
  • internal/ratelimit/service_test.go
  • internal/ratelimit/store_mongodb.go
  • internal/ratelimit/store_mongodb_test.go
  • internal/ratelimit/store_postgresql.go
  • internal/ratelimit/store_sqlite.go
  • internal/ratelimit/store_sqlite_test.go
  • internal/ratelimit/types.go
  • internal/ratelimit/types_test.go
  • internal/server/messages_handler.go
  • internal/server/passthrough_service.go
  • internal/server/ratelimit_support.go
  • internal/server/translated_inference_service.go

Comment thread internal/ratelimit/store_mongodb.go Outdated
SantiagoDePolonia and others added 2 commits July 5, 2026 23:53
When the retry after a manual insert race fails for a different reason,
wrap that failure instead of the original duplicate-key error, so the
real cause is diagnosable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every model row and provider group header on the Models page gets a gauge
button that opens an effective-limits inspector: the model's own rules, the
provider rules it shares, and the global root user-path rules, each with
live usage and add/edit actions. The editor opened from the inspector
returns to it on close.

The rate-limit editor's scope, subject, and period are now editable while
editing. Changing any of them moves the rule: the new key is created first
and the old rule deleted after, so a failed delete can never lose the rule,
and an edit that merely respells the same identity (case, slashes) is
detected via a client-side mirror of the server normalization and treated
as an in-place update. A hint in the form explains that moving a rule
restarts its live counters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SantiagoDePolonia and others added 2 commits July 6, 2026 13:45
The rate-limit inspector rows now double as usage meters: the row
background fills left-to-right with the share of the most constrained cap
used, ramping green to warning at 75% and danger at 100%, with the exact
percentage in the row tooltip.

The Models page gauge buttons carry the limit state: fully painted (with
the active dot) when the model or provider has its own rules, half painted
when only provider or global root rules throttle it, plain when nothing
applies. Tooltips spell the state out, and the models page now loads the
rule list so the indicators are correct on first render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requestID := requestIDFromContextOrHeader(c.Request())

if err := enforceBudget(c, s.budgetChecker); err != nil {
release, err := enforceAdmission(c, s.rateLimiter, s.budgetChecker, rateLimitRouteFromWorkflow(workflow))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Failover is bypassed
enforceAdmission rejects provider/model-scoped breaches before the orchestrator runs, so a direct request whose primary route is saturated returns 429 instead of entering ExecuteChatCompletion and letting RouteGate skip to configured failover targets. The failover gate in internal/gateway/failover.go only runs after a primary dispatch error, but this line prevents that dispatch entirely.

T-Rex Ran code and verified through T-Rex

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/ratelimit/store_mongodb.go (1)

90-141: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Low test coverage on concurrency-sensitive upsert/retry logic.

This function contains the config-vs-manual precedence filter and the duplicate-key race retry — both areas that were just bug-fixed per prior review rounds. Codecov reports only 9.43% patch coverage for this file. Given the subtlety of the retry/collision semantics (and that a test file already exists for this package), targeted table-driven tests covering: (1) manual write racing a concurrent insert, (2) config seed colliding with an existing manual row, and (3) retry failing for a reason other than duplicate-key, would meaningfully de-risk this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ratelimit/store_mongodb.go` around lines 90 - 141, The
concurrency-sensitive upsert path in MongoDBStore.upsertNormalizedRules lacks
targeted coverage for the config/manual precedence filter and duplicate-key
retry handling. Add table-driven tests in the existing package test file that
exercise: a manual rule upsert racing a concurrent insert and succeeding after
the retry path, a config-sourced rule colliding with an existing manual row and
being skipped by duplicateKeyErrorsOnConfigRulesOnly, and a non-duplicate error
on the retry path returning a wrapped failure from upsertNormalizedRules. Use
the unique symbols upsertNormalizedRules, duplicateKeyErrorsOnConfigRulesOnly,
and isOnlyDuplicateKeyErrors to keep the tests aligned with the intended
semantics.
internal/admin/dashboard/static/js/dashboard.js (1)

449-466: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include Models-page rate-limit dialogs in overlayDialogOpen()
page-models.html renders the rate-limit inspector/editor, but this guard only treats rateLimitFormOpen as modal state on this.page === "rate-limits" and never checks rateLimitInspectorOpen. When the editor is opened from Models, body.dashboard-modal-open stays off and the page can keep scrolling behind the dialog.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/js/dashboard.js` around lines 449 - 466,
`overlayDialogOpen()` is missing the Models-page rate-limit modal state, so add
the Models-specific rate-limit inspector/editor flag(s) to this guard alongside
`rateLimitFormOpen`. Update the `Dashboard` state check so it treats the
rate-limit dialog as open when `this.page === "models"` and
`rateLimitInspectorOpen` is active, matching the existing `rateLimitFormOpen`
handling used for `this.page === "rate-limits"`. This will ensure the modal-open
class stays applied while the Models-page rate-limit dialog is visible.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/admin/dashboard/static/js/modules/rate-limits.js`:
- Around line 705-725: Both rateLimitGaugeClassForModel and
rateLimitGaugeClassForProvider recompute the same rule-matching checks against
this.rateLimits on every call, which is repeated multiple times per row.
Refactor these helpers to avoid re-scanning the full rules list for each
class/label usage by computing the active/inherited state once per row or by
introducing shared cached helper logic using rateLimitInspectorModelID,
rateLimitRuleMatchesModel, rateLimitRuleMatchesProvider, and
hasGlobalRateLimits.

In `@internal/admin/dashboard/static/js/modules/rate-limits.test.cjs`:
- Around line 277-281: Replace the JSON.stringify-based equality checks in
rate-limits.test.cjs with assert.deepEqual so the assertions compare the request
body objects directly and produce better failures. Update the specific body
assertion near the calls[1].body check, and apply the same refactor to the other
matching assertions mentioned in the review, keeping the existing expected
object shapes and using the same test helpers around the affected rate limit
request cases.

In `@internal/admin/dashboard/templates/rate-limit-editor.html`:
- Around line 15-22: The dialog in rateLimitFormOpen currently uses a static
aria-label on the rate-limit editor, while the visible title in the
editor-header h3 changes between edit and create modes. Update the dialog markup
to use aria-labelledby tied to the h3 in the editor header (give that heading a
stable id) so assistive tech announces the same dynamic title as the UI; keep
the submit flow and rateLimitFormPayload behavior unchanged.

---

Outside diff comments:
In `@internal/admin/dashboard/static/js/dashboard.js`:
- Around line 449-466: `overlayDialogOpen()` is missing the Models-page
rate-limit modal state, so add the Models-specific rate-limit inspector/editor
flag(s) to this guard alongside `rateLimitFormOpen`. Update the `Dashboard`
state check so it treats the rate-limit dialog as open when `this.page ===
"models"` and `rateLimitInspectorOpen` is active, matching the existing
`rateLimitFormOpen` handling used for `this.page === "rate-limits"`. This will
ensure the modal-open class stays applied while the Models-page rate-limit
dialog is visible.

In `@internal/ratelimit/store_mongodb.go`:
- Around line 90-141: The concurrency-sensitive upsert path in
MongoDBStore.upsertNormalizedRules lacks targeted coverage for the config/manual
precedence filter and duplicate-key retry handling. Add table-driven tests in
the existing package test file that exercise: a manual rule upsert racing a
concurrent insert and succeeding after the retry path, a config-sourced rule
colliding with an existing manual row and being skipped by
duplicateKeyErrorsOnConfigRulesOnly, and a non-duplicate error on the retry path
returning a wrapped failure from upsertNormalizedRules. Use the unique symbols
upsertNormalizedRules, duplicateKeyErrorsOnConfigRulesOnly, and
isOnlyDuplicateKeyErrors to keep the tests aligned with the intended semantics.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1f356617-9033-4f56-a59c-c3aa454b00b2

📥 Commits

Reviewing files that changed from the base of the PR and between 88bff16 and 9f8d24e.

📒 Files selected for processing (12)
  • docs/features/rate-limits.mdx
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/dashboard.js
  • internal/admin/dashboard/static/js/modules/rate-limits.js
  • internal/admin/dashboard/static/js/modules/rate-limits.test.cjs
  • internal/admin/dashboard/templates/gauge-icon.html
  • internal/admin/dashboard/templates/model-table-body.html
  • internal/admin/dashboard/templates/page-models.html
  • internal/admin/dashboard/templates/page-rate-limits.html
  • internal/admin/dashboard/templates/rate-limit-editor.html
  • internal/admin/dashboard/templates/rate-limit-inspector.html
  • internal/ratelimit/store_mongodb.go

Comment thread internal/admin/dashboard/static/js/modules/rate-limits.js
Comment on lines +277 to +281
assert.equal(JSON.stringify(calls[1].body), JSON.stringify({
scope: 'user_path',
subject: '/team',
limit_key: { period_seconds: 60 }
}));

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.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer assert.deepEqual over JSON.stringify comparisons.

Using JSON.stringify for object/array equality works here but produces less readable diffs on failure and is incidentally order-sensitive.

♻️ Example refactor
-    assert.equal(JSON.stringify(sections.map((section) => section.key)), JSON.stringify(['model', 'provider', 'global']));
+    assert.deepEqual(sections.map((section) => section.key), ['model', 'provider', 'global']);

Also applies to: 342-354

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/js/modules/rate-limits.test.cjs` around lines
277 - 281, Replace the JSON.stringify-based equality checks in
rate-limits.test.cjs with assert.deepEqual so the assertions compare the request
body objects directly and produce better failures. Update the specific body
assertion near the calls[1].body check, and apply the same refactor to the other
matching assertions mentioned in the review, keeping the existing expected
object shapes and using the same test helpers around the affected rate limit
request cases.

Comment thread internal/admin/dashboard/templates/rate-limit-editor.html Outdated
SantiagoDePolonia and others added 2 commits July 6, 2026 14:17
…lover

Admission previously returned 429 for provider/model-scoped breaches before
dispatch ever ran, so the failover sweep (and its RouteGate) never got the
chance to serve the request from another target — breaking the advertised
"429 only when no viable target remains" behavior for direct routes with
failover rules.

The primary provider still must not be called (it would happily serve the
request and defeat the gateway's limit), so the breach becomes a synthetic
primary failure instead: when failover targets exist, admission re-admits
the request against its consumer limits only, stamps the stored 429 into
the context, and dispatch skips the primary call and enters the failover
sweep seeded with that error — for both response and stream paths. The
sweep still skips rate-saturated candidates, and the original 429 with its
Retry-After surfaces unchanged when nothing can take the request.

Consumer (user-path) breaches never defer — switching targets cannot
relieve them — and embeddings keep rejecting outright since their failover
is disabled. The exceeded rule now travels in the gateway error's unwrap
chain (never serialized) so admission can inspect its scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ound

- Memoize the Models-page gauge indicator state per rules-list generation:
  Alpine evaluates the class and label bindings several times per row on
  every render, each previously rescanning the full rules list.
- Count the Models-page rate-limit editor and inspector in
  overlayDialogOpen() so the modal-open treatment applies there too.
- Label the rate-limit editor dialog via aria-labelledby on its heading so
  assistive tech announces the Edit/Create title the UI shows.
- Extract the Mongo bulk-upsert error decision into classifyBulkWriteError
  and pin its contract (success, config-shadowing skip, manual-race retry,
  real failure) with a table test.

Skipped: replacing the JSON.stringify assertions with assert.deepEqual —
the tests use node:assert/strict where deepEqual is prototype-sensitive,
and vm.runInContext objects carry foreign prototypes, so the suggested
change fails exactly the way these tests originally did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.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.

2 participants