feat(ratelimit): cluster-level rate limiting via shared Redis - #607

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798
Jun 15, 2026
Merged

feat(ratelimit): cluster-level rate limiting via shared Redis#607
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rate-limit counters live in per-process memory (FixedWindowCounter in a DashMap), so each DP replica counts only the traffic it personally served. A cluster of N replicas behind a load balancer therefore enforces N× every configured limit — a key capped at rpm: 1 gets one request per replica per minute. The reporter saw exactly this: instance :3000 returns 429 while :3001 still serves the same key.

Approach

Introduce a pluggable RateStore backend behind Limiter:

  • LocalStore — the historical per-process fixed-window counters, unchanged. Stays the default, so single-node and dev deployments behave exactly as before (all prior limiter unit tests pass against it verbatim).
  • RedisStore — shares the counters across every replica through one Redis, so the whole cluster enforces one global window. Counter math mirrors LocalStore/FixedWindowCounter (wall-clock-aligned windows now - now % window) so swapping memory ↔ redis doesn't change observable limits, only whether the count is shared.

RedisStore details:

  • One Lua per bucket does the atomic, all-or-nothing acquire: concurrency gate + token check-only + request check-and-increment. redis.call('TIME') is used for now so window boundaries are identical across replicas regardless of host clock skew.
  • Keys are namespaced aisix:rl: and hash-tagged {<bucket>} so all of a bucket's keys co-locate on one Redis Cluster slot (the per-bucket Lua stays atomic). The Redis may be the same instance used for the response cache.
  • All dimensions are shared, including concurrency: it's tracked as a ZSET semaphore (member → score=now) where acquire prunes entries older than concurrency_ttl_secs before counting, so a slot held by a crashed/hung replica is reclaimed within the TTL. (A window-TTL counter would mishandle long streaming responses — the same reason StreamConcurrencyGuard exists.)
  • On any Redis error the store fails open to per-replica in-memory counting (logged once): traffic keeps flowing during an outage and global enforcement resumes when Redis recovers.

The enforcement path (pre_commit/commit_tokens/peek) is now async; concurrency release stays a synchronous Drop (the Redis backend detaches a ZREM, bounded by the TTL prune). All LLM endpoints share the quota::enforce / enforce_rate_limit helpers, so every endpoint inherits the fix uniformly.

Configuration

New ratelimit block, defaulting to memory (current behaviour):

ratelimit:
backend: "redis"# memory | redisredis:
url: "redis://host:6379"concurrency_ttl_secs: 300

Reachable by env on managed/containerized deployments: AISIX_RATELIMIT__BACKEND=redis, AISIX_RATELIMIT__REDIS__URL=.... backend: redis without a redis block is rejected at boot.

Behaviour changes

  • Default deployments are unchanged (backend: memory).
  • Multi-replica deployments that opt into backend: redis now enforce limits cluster-wide instead of per replica.

Tests

  • Rust integration (crates/aisix-ratelimit/tests/redis_integration.rs, gated on RATELIMIT_TEST_REDIS_URL, CI provisions redis:7-alpine): two RedisStore instances share rpm/rps/tpm/concurrency counters; rps window rollover; stale concurrency slot reclaimed after TTL.
  • DP e2e (tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts): spins up two real aisix binaries on one shared etcd + one shared Redis with an rpm: 1 key — request to replica A → 200, request to replica B → 429 + Retry-After (the exact issue repro). A contrast suite with the default memory backend shows both replicas serve the request (the per-replica bug).
  • All existing limiter unit tests pass against LocalStore; new config-validation unit tests cover the ratelimit.redis rules.

Fixes api7/AISIX-Cloud#798

Summary by CodeRabbit

  • New Features

    • Added a ratelimit configuration block with selectable backend (memory default or redis) for cluster-wide rate limiting across multiple replicas.
    • Redis backend supports shared concurrency enforcement via concurrency_ttl_secs, enabling cross-replica in-flight slot reclamation.
    • When Redis is unavailable, rate limiting degrades gracefully to per-replica in-memory behavior while logging the incident.
  • Documentation

    • Expanded rate-limit documentation with “single node vs cluster” storage guidance and multi-replica behavior examples.
  • Tests

    • Added Redis-backed and multi-replica E2E coverage for rate-limit correctness and retry behavior.

Fixes api7/AISIX-Cloud#788

Rate-limit counters lived in per-process memory, so an N-replica DP
cluster enforced N× every configured limit (a key capped at rpm:1 got
one request per replica per minute). Add a Redis-backed shared store so
the whole cluster enforces one global window.
- Introduce a `RateStore` backend behind `Limiter`: `LocalStore`
(unchanged in-memory default) and `RedisStore` (Lua check-and-increment
over wall-clock-aligned fixed windows, `redis.call('TIME')` for
cross-replica window consistency, hash-tagged keys for Cluster slot
co-location). All dimensions are shared — rps/rpm/rph/rpd/tpm/tpd plus
concurrency, tracked as a crash-safe ZSET semaphore reclaimed after
`concurrency_ttl_secs`. On a Redis outage the store fails open to
per-replica counting.
- Make the enforcement path async (`pre_commit`/`commit_tokens`/`peek`);
concurrency release stays a sync `Drop` (Redis detaches a ZREM).
- New `ratelimit` config block (`backend: memory|redis`, `redis`,
`concurrency_ttl_secs`), enabled via env on managed deployments.
Tests: Rust integration (gated on RATELIMIT_TEST_REDIS_URL) for shared
rpm/rps/tpm/concurrency + TTL reclaim; DP e2e spins two real binaries on
one Redis (A→200, B→429) plus a memory-backend regression (both 200).
Fixesapi7/AISIX-Cloud#798
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5736522-d2aa-4a9c-9eec-8edab704f19a

📥 Commits

Reviewing files that changed from the base of the PR and between 5102235 and 3dc567c.

📒 Files selected for processing (4)
  • crates/aisix-core/src/config.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/aisix-server/src/main.rs
  • crates/aisix-core/src/config.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
  • crates/aisix-ratelimit/tests/redis_integration.rs

📝 Walkthrough

Walkthrough

Adds a pluggable RateStore trait with LocalStore (in-process fixed-window) and RedisStore (Lua-script atomic, fail-open) backends. Refactors Limiter, Reservation, MultiReservation, and StreamConcurrencyGuard from sync clock-generic to async store-backed. Migrates proxy quota, chat, and embeddings paths to async reservation APIs. Wires conditional Redis initialization at server startup via a new ratelimit config block with validation and environment variable support.

Changes

Cluster-wide rate limiting

Layer / File(s)Summary
RateLimitConfig types, validation, and re-exports
crates/aisix-core/src/config.rs, crates/aisix-core/src/lib.rs, config.example.yaml, config.managed.yaml
Adds RateLimitConfig struct and RateLimitBackend enum to Config, a boot-time check requiring ratelimit.redis block and non-zero concurrency_ttl_secs when backend is redis, four config tests, public re-exports of the new types, and documented config file examples.
RateStore trait and LocalStore backend
crates/aisix-ratelimit/src/store/mod.rs, crates/aisix-ratelimit/src/store/local.rs
Defines the RateStore trait (async acquire/commit/peek, sync release/add_tokens), shared window-dimension constants and helpers (request_dims, token_dims), and the in-process LocalStore with per-key DashMap state, layered request-window rollback-on-reject acquire logic, and peek.
RedisStore Lua scripts and RateStore implementation
crates/aisix-ratelimit/Cargo.toml, crates/aisix-ratelimit/src/store/redis.rs
Adds async-trait, redis, uuid dependencies; embeds four Lua scripts (ACQUIRE, COMMIT, ADD_TOKENS, PEEK) for atomic Redis operations with ZSET concurrency semaphore; implements RedisStore with fail-open fallback to LocalStore, fire-and-forget release/add_tokens via spawned tasks, and connect/with_conc_ttl constructors.
Limiter refactored to async store-backed API
crates/aisix-ratelimit/src/lib.rs, crates/aisix-ratelimit/src/limiter.rs
Replaces clock-generic in-memory Limiter<C> with a store-backed Limiter backed by Arc<dyn RateStore>; makes pre_commit and peek async; reworks Reservation, MultiReservation, and StreamConcurrencyGuard to own store references and release concurrency on drop; converts all unit tests to #[tokio::test].
Proxy quota, chat, and embeddings async migration
crates/aisix-proxy/src/quota.rs, crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/embeddings.rs
Converts reserve_layers, enforce, and enforce_rate_limit to async fn and awaits all pre_commit calls; updates all commit_tokens, peek, into_stream_hold, and dispatch_ensemble call sites in chat and embeddings to use the async API without passing limiter to hold().
Server startup conditional Redis initialization
crates/aisix-server/src/main.rs, .github/workflows/ci.yml
Replaces unconditional Limiter::new() with a branch that connects RedisStore when cfg.ratelimit.backend is Redis, applies concurrency_ttl_secs, and wraps it in Limiter::with_store; adds RATELIMIT_TEST_REDIS_URL to CI environment for integration tests.
Redis integration tests, E2E cluster tests, and docs
crates/aisix-ratelimit/tests/redis_integration.rs, tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts, docs/configuration/rate-limits.md
Adds RedisStore integration tests for shared RPM/RPS/token/concurrency/TTL across two store instances; adds E2E cluster tests asserting cross-replica 429 enforcement with Redis backend and independent per-replica 200s with in-memory backend; documents counter storage backends and operator guidance for multi-replica deployments.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ProxyHandler as chat.rs / embeddings.rs
participant Quota as quota.rs enforce_rate_limit
participant Limiter
participant RateStore as LocalStore or RedisStore
participant Redis
Client->>ProxyHandler: POST /v1/chat/completions
ProxyHandler->>Quota: enforce_rate_limit(state, auth, model_rl).await
Quota->>Limiter: pre_commit(key, limits).await
Limiter->>RateStore: acquire(key, limits, member).await
alt Redis backend
RateStore->>Redis: EVALSHA ACQUIRE_LUA
Redis-->>RateStore: ok / rate_limit_error
end
RateStore-->>Limiter: Ok(()) or RateLimitError
Limiter-->>Quota: Reservation
Quota-->>ProxyHandler: MultiReservation
alt streaming
ProxyHandler->>ProxyHandler: into_stream_hold() → StreamConcurrencyGuard
Note over ProxyHandler: concurrency held for stream lifetime
ProxyHandler->>RateStore: add_tokens_post_stream(key, tokens)
Note over ProxyHandler: guard dropped → release(key, member)
else non-streaming / cache-hit
ProxyHandler->>ProxyHandler: reservation.commit_tokens(tokens).await
ProxyHandler->>RateStore: commit(key, tokens, member).await
alt Redis backend
RateStore->>Redis: EVALSHA COMMIT_LUA
end
end
ProxyHandler-->>Client: 200 OK or 429 Too Many Requests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • api7/ai-gateway#481: Both PRs modify MultiReservation::into_stream_hold and StreamConcurrencyGuard to hold concurrency permits for the lifetime of streaming responses, with this PR restructuring the guard ownership model to support the async store backend.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately reflects the main change: introducing cluster-level rate limiting via shared Redis backend. It is concise, specific, and clearly describes the primary objective.
Linked Issues check✅ PassedAll requirements from issue #798 are met: the PR implements cluster-level rate limiting via shared Redis, with LocalStore as default for backward compatibility, atomic enforcement via Lua scripts, ZSET-based concurrency tracking, fail-open behavior on Redis errors, configuration via ratelimit.backend, and comprehensive testing including e2e multi-replica verification.
Out of Scope Changes check✅ PassedAll changes are directly scoped to implementing cluster-level rate limiting: RateStore abstraction, LocalStore and RedisStore implementations, async refactoring of enforcement paths, configuration additions, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@crates/aisix-core/src/config.rs`:
- Around line 704-708: Add a validation check in the same config validation
block that checks the Redis backend to also enforce that concurrency_ttl_secs
must be a positive value (greater than 0) when the Redis backend is selected.
When Redis semaphore reclamation is enabled, a concurrency_ttl_secs value of 0
would immediately reclaim active slots and disable concurrency limiting, so add
a condition to return a BootstrapError::Config with a clear message if
concurrency_ttl_secs is 0 or negative while using the Redis backend, similar to
the existing Redis backend validation pattern.
In `@crates/aisix-ratelimit/tests/redis_integration.rs`:
- Around line 161-167: Replace the hardcoded 200ms sleep after a.release(&key,
"a-1") with bounded polling that repeatedly attempts the b.acquire(&key,
&limits, "b-2") operation until it succeeds or a reasonable timeout is reached.
Instead of assuming a fixed propagation delay, use a loop with
tokio::time::timeout or a similar mechanism to poll for the actual condition
(slot availability) rather than sleeping, which makes the test robust to varying
CI executor speeds.
In `@crates/aisix-server/src/main.rs`:
- Around line 379-401: The rate limiter selection logic does not respect the
`ratelimit.backend` configuration setting. Currently, the match expression at
line 385 only checks if `cfg.ratelimit.redis` is present, which means the redis
backend can be used even when `backend: memory` is configured. Modify the
branching logic to check both the `cfg.ratelimit.backend` value AND the presence
of `cfg.ratelimit.redis`. Only instantiate the redis-backed limiter when
`backend` is explicitly set to redis and the `cfg.ratelimit.redis` block is
present; otherwise, use the memory backend with `Limiter::new()`. This ensures
the user's backend configuration choice is honored regardless of whether a redis
block exists in the config.
In `@tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts`:
- Around line 140-147: The afterAll hook in the ratelimit-cluster-e2e.test.ts
file calls deletePrefix without checking whether the etcd infrastructure is
available, which causes test suite failures when etcd is unavailable even though
tests correctly skip via ctx.skip() when infra is down. Guard the deletePrefix
call (at line 146 in the afterAll hook and also at line 195 in another afterAll
hook) behind a readiness check to ensure cleanup only runs if etcd is actually
available, preventing teardown failures from failing the entire suite when
infrastructure is unavailable.
🪄 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: CHILL

Plan: Pro

Run ID: 47eb2a68-4b09-4fc1-8b62-5de06116e6e4

📥 Commits

Reviewing files that changed from the base of the PR and between ca2542e and 5102235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-ratelimit/Cargo.toml
  • crates/aisix-ratelimit/src/lib.rs
  • crates/aisix-ratelimit/src/limiter.rs
  • crates/aisix-ratelimit/src/store/local.rs
  • crates/aisix-ratelimit/src/store/mod.rs
  • crates/aisix-ratelimit/src/store/redis.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • docs/configuration/rate-limits.md
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts

Comment threadcrates/aisix-core/src/config.rs Outdated
Comment threadcrates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment threadcrates/aisix-server/src/main.rs
Comment threadtests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
… robustness
- main.rs: select the rate-limit store on `ratelimit.backend`, not on
`ratelimit.redis` presence, so a stray redis block under
`backend: memory` no longer silently activates Redis.
- config: reject `concurrency_ttl_secs: 0` for the redis backend (a zero
TTL prunes a slot in the same second it is taken, disabling concurrency
limiting). + unit test.
- redis integration test: poll (bounded) for the detached ZREM instead of
a fixed 200ms sleep.
- cluster e2e: guard the afterAll deletePrefix behind the readiness flag
so teardown doesn't fail when infra is unavailable.
@jarvis9443
jarvis9443 merged commit dbdcf20 into mainJun 15, 2026
10 checks passed
@jarvis9443
jarvis9443 deleted the feat/cluster-ratelimit-798 branch June 15, 2026 04:11
moonming added a commit that referenced this pull request Jun 15, 2026
)
Unbreaks main: #606 merged onto a main with #607's MultiReservation API change (semantic conflict, no textual conflict). Ports the streaming-ensemble reservation code to the new API and fixes the latent un-awaited commit_tokens (panel tokens were never billed on streaming error exits). Verified green locally + CI.
Sign up for freeto 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.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(ratelimit): cluster-level rate limiting via shared Redis - #607

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798
Jun 15, 2026
Merged

feat(ratelimit): cluster-level rate limiting via shared Redis#607
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rate-limit counters live in per-process memory (FixedWindowCounter in a DashMap), so each DP replica counts only the traffic it personally served. A cluster of N replicas behind a load balancer therefore enforces N× every configured limit — a key capped at rpm: 1 gets one request per replica per minute. The reporter saw exactly this: instance :3000 returns 429 while :3001 still serves the same key.

Approach

Introduce a pluggable RateStore backend behind Limiter:

  • LocalStore — the historical per-process fixed-window counters, unchanged. Stays the default, so single-node and dev deployments behave exactly as before (all prior limiter unit tests pass against it verbatim).
  • RedisStore — shares the counters across every replica through one Redis, so the whole cluster enforces one global window. Counter math mirrors LocalStore/FixedWindowCounter (wall-clock-aligned windows now - now % window) so swapping memory ↔ redis doesn't change observable limits, only whether the count is shared.

RedisStore details:

  • One Lua per bucket does the atomic, all-or-nothing acquire: concurrency gate + token check-only + request check-and-increment. redis.call('TIME') is used for now so window boundaries are identical across replicas regardless of host clock skew.
  • Keys are namespaced aisix:rl: and hash-tagged {<bucket>} so all of a bucket's keys co-locate on one Redis Cluster slot (the per-bucket Lua stays atomic). The Redis may be the same instance used for the response cache.
  • All dimensions are shared, including concurrency: it's tracked as a ZSET semaphore (member → score=now) where acquire prunes entries older than concurrency_ttl_secs before counting, so a slot held by a crashed/hung replica is reclaimed within the TTL. (A window-TTL counter would mishandle long streaming responses — the same reason StreamConcurrencyGuard exists.)
  • On any Redis error the store fails open to per-replica in-memory counting (logged once): traffic keeps flowing during an outage and global enforcement resumes when Redis recovers.

The enforcement path (pre_commit/commit_tokens/peek) is now async; concurrency release stays a synchronous Drop (the Redis backend detaches a ZREM, bounded by the TTL prune). All LLM endpoints share the quota::enforce / enforce_rate_limit helpers, so every endpoint inherits the fix uniformly.

Configuration

New ratelimit block, defaulting to memory (current behaviour):

ratelimit:
backend: "redis"# memory | redisredis:
url: "redis://host:6379"concurrency_ttl_secs: 300

Reachable by env on managed/containerized deployments: AISIX_RATELIMIT__BACKEND=redis, AISIX_RATELIMIT__REDIS__URL=.... backend: redis without a redis block is rejected at boot.

Behaviour changes

  • Default deployments are unchanged (backend: memory).
  • Multi-replica deployments that opt into backend: redis now enforce limits cluster-wide instead of per replica.

Tests

  • Rust integration (crates/aisix-ratelimit/tests/redis_integration.rs, gated on RATELIMIT_TEST_REDIS_URL, CI provisions redis:7-alpine): two RedisStore instances share rpm/rps/tpm/concurrency counters; rps window rollover; stale concurrency slot reclaimed after TTL.
  • DP e2e (tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts): spins up two real aisix binaries on one shared etcd + one shared Redis with an rpm: 1 key — request to replica A → 200, request to replica B → 429 + Retry-After (the exact issue repro). A contrast suite with the default memory backend shows both replicas serve the request (the per-replica bug).
  • All existing limiter unit tests pass against LocalStore; new config-validation unit tests cover the ratelimit.redis rules.

Fixes api7/AISIX-Cloud#798

Summary by CodeRabbit

  • New Features

    • Added a ratelimit configuration block with selectable backend (memory default or redis) for cluster-wide rate limiting across multiple replicas.
    • Redis backend supports shared concurrency enforcement via concurrency_ttl_secs, enabling cross-replica in-flight slot reclamation.
    • When Redis is unavailable, rate limiting degrades gracefully to per-replica in-memory behavior while logging the incident.
  • Documentation

    • Expanded rate-limit documentation with “single node vs cluster” storage guidance and multi-replica behavior examples.
  • Tests

    • Added Redis-backed and multi-replica E2E coverage for rate-limit correctness and retry behavior.

Fixes api7/AISIX-Cloud#788

Rate-limit counters lived in per-process memory, so an N-replica DP
cluster enforced N× every configured limit (a key capped at rpm:1 got
one request per replica per minute). Add a Redis-backed shared store so
the whole cluster enforces one global window.
- Introduce a `RateStore` backend behind `Limiter`: `LocalStore`
(unchanged in-memory default) and `RedisStore` (Lua check-and-increment
over wall-clock-aligned fixed windows, `redis.call('TIME')` for
cross-replica window consistency, hash-tagged keys for Cluster slot
co-location). All dimensions are shared — rps/rpm/rph/rpd/tpm/tpd plus
concurrency, tracked as a crash-safe ZSET semaphore reclaimed after
`concurrency_ttl_secs`. On a Redis outage the store fails open to
per-replica counting.
- Make the enforcement path async (`pre_commit`/`commit_tokens`/`peek`);
concurrency release stays a sync `Drop` (Redis detaches a ZREM).
- New `ratelimit` config block (`backend: memory|redis`, `redis`,
`concurrency_ttl_secs`), enabled via env on managed deployments.
Tests: Rust integration (gated on RATELIMIT_TEST_REDIS_URL) for shared
rpm/rps/tpm/concurrency + TTL reclaim; DP e2e spins two real binaries on
one Redis (A→200, B→429) plus a memory-backend regression (both 200).
Fixesapi7/AISIX-Cloud#798
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5736522-d2aa-4a9c-9eec-8edab704f19a

📥 Commits

Reviewing files that changed from the base of the PR and between 5102235 and 3dc567c.

📒 Files selected for processing (4)
  • crates/aisix-core/src/config.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/aisix-server/src/main.rs
  • crates/aisix-core/src/config.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
  • crates/aisix-ratelimit/tests/redis_integration.rs

📝 Walkthrough

Walkthrough

Adds a pluggable RateStore trait with LocalStore (in-process fixed-window) and RedisStore (Lua-script atomic, fail-open) backends. Refactors Limiter, Reservation, MultiReservation, and StreamConcurrencyGuard from sync clock-generic to async store-backed. Migrates proxy quota, chat, and embeddings paths to async reservation APIs. Wires conditional Redis initialization at server startup via a new ratelimit config block with validation and environment variable support.

Changes

Cluster-wide rate limiting

Layer / File(s)Summary
RateLimitConfig types, validation, and re-exports
crates/aisix-core/src/config.rs, crates/aisix-core/src/lib.rs, config.example.yaml, config.managed.yaml
Adds RateLimitConfig struct and RateLimitBackend enum to Config, a boot-time check requiring ratelimit.redis block and non-zero concurrency_ttl_secs when backend is redis, four config tests, public re-exports of the new types, and documented config file examples.
RateStore trait and LocalStore backend
crates/aisix-ratelimit/src/store/mod.rs, crates/aisix-ratelimit/src/store/local.rs
Defines the RateStore trait (async acquire/commit/peek, sync release/add_tokens), shared window-dimension constants and helpers (request_dims, token_dims), and the in-process LocalStore with per-key DashMap state, layered request-window rollback-on-reject acquire logic, and peek.
RedisStore Lua scripts and RateStore implementation
crates/aisix-ratelimit/Cargo.toml, crates/aisix-ratelimit/src/store/redis.rs
Adds async-trait, redis, uuid dependencies; embeds four Lua scripts (ACQUIRE, COMMIT, ADD_TOKENS, PEEK) for atomic Redis operations with ZSET concurrency semaphore; implements RedisStore with fail-open fallback to LocalStore, fire-and-forget release/add_tokens via spawned tasks, and connect/with_conc_ttl constructors.
Limiter refactored to async store-backed API
crates/aisix-ratelimit/src/lib.rs, crates/aisix-ratelimit/src/limiter.rs
Replaces clock-generic in-memory Limiter<C> with a store-backed Limiter backed by Arc<dyn RateStore>; makes pre_commit and peek async; reworks Reservation, MultiReservation, and StreamConcurrencyGuard to own store references and release concurrency on drop; converts all unit tests to #[tokio::test].
Proxy quota, chat, and embeddings async migration
crates/aisix-proxy/src/quota.rs, crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/embeddings.rs
Converts reserve_layers, enforce, and enforce_rate_limit to async fn and awaits all pre_commit calls; updates all commit_tokens, peek, into_stream_hold, and dispatch_ensemble call sites in chat and embeddings to use the async API without passing limiter to hold().
Server startup conditional Redis initialization
crates/aisix-server/src/main.rs, .github/workflows/ci.yml
Replaces unconditional Limiter::new() with a branch that connects RedisStore when cfg.ratelimit.backend is Redis, applies concurrency_ttl_secs, and wraps it in Limiter::with_store; adds RATELIMIT_TEST_REDIS_URL to CI environment for integration tests.
Redis integration tests, E2E cluster tests, and docs
crates/aisix-ratelimit/tests/redis_integration.rs, tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts, docs/configuration/rate-limits.md
Adds RedisStore integration tests for shared RPM/RPS/token/concurrency/TTL across two store instances; adds E2E cluster tests asserting cross-replica 429 enforcement with Redis backend and independent per-replica 200s with in-memory backend; documents counter storage backends and operator guidance for multi-replica deployments.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ProxyHandler as chat.rs / embeddings.rs
participant Quota as quota.rs enforce_rate_limit
participant Limiter
participant RateStore as LocalStore or RedisStore
participant Redis
Client->>ProxyHandler: POST /v1/chat/completions
ProxyHandler->>Quota: enforce_rate_limit(state, auth, model_rl).await
Quota->>Limiter: pre_commit(key, limits).await
Limiter->>RateStore: acquire(key, limits, member).await
alt Redis backend
RateStore->>Redis: EVALSHA ACQUIRE_LUA
Redis-->>RateStore: ok / rate_limit_error
end
RateStore-->>Limiter: Ok(()) or RateLimitError
Limiter-->>Quota: Reservation
Quota-->>ProxyHandler: MultiReservation
alt streaming
ProxyHandler->>ProxyHandler: into_stream_hold() → StreamConcurrencyGuard
Note over ProxyHandler: concurrency held for stream lifetime
ProxyHandler->>RateStore: add_tokens_post_stream(key, tokens)
Note over ProxyHandler: guard dropped → release(key, member)
else non-streaming / cache-hit
ProxyHandler->>ProxyHandler: reservation.commit_tokens(tokens).await
ProxyHandler->>RateStore: commit(key, tokens, member).await
alt Redis backend
RateStore->>Redis: EVALSHA COMMIT_LUA
end
end
ProxyHandler-->>Client: 200 OK or 429 Too Many Requests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • api7/ai-gateway#481: Both PRs modify MultiReservation::into_stream_hold and StreamConcurrencyGuard to hold concurrency permits for the lifetime of streaming responses, with this PR restructuring the guard ownership model to support the async store backend.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately reflects the main change: introducing cluster-level rate limiting via shared Redis backend. It is concise, specific, and clearly describes the primary objective.
Linked Issues check✅ PassedAll requirements from issue #798 are met: the PR implements cluster-level rate limiting via shared Redis, with LocalStore as default for backward compatibility, atomic enforcement via Lua scripts, ZSET-based concurrency tracking, fail-open behavior on Redis errors, configuration via ratelimit.backend, and comprehensive testing including e2e multi-replica verification.
Out of Scope Changes check✅ PassedAll changes are directly scoped to implementing cluster-level rate limiting: RateStore abstraction, LocalStore and RedisStore implementations, async refactoring of enforcement paths, configuration additions, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@crates/aisix-core/src/config.rs`:
- Around line 704-708: Add a validation check in the same config validation
block that checks the Redis backend to also enforce that concurrency_ttl_secs
must be a positive value (greater than 0) when the Redis backend is selected.
When Redis semaphore reclamation is enabled, a concurrency_ttl_secs value of 0
would immediately reclaim active slots and disable concurrency limiting, so add
a condition to return a BootstrapError::Config with a clear message if
concurrency_ttl_secs is 0 or negative while using the Redis backend, similar to
the existing Redis backend validation pattern.
In `@crates/aisix-ratelimit/tests/redis_integration.rs`:
- Around line 161-167: Replace the hardcoded 200ms sleep after a.release(&key,
"a-1") with bounded polling that repeatedly attempts the b.acquire(&key,
&limits, "b-2") operation until it succeeds or a reasonable timeout is reached.
Instead of assuming a fixed propagation delay, use a loop with
tokio::time::timeout or a similar mechanism to poll for the actual condition
(slot availability) rather than sleeping, which makes the test robust to varying
CI executor speeds.
In `@crates/aisix-server/src/main.rs`:
- Around line 379-401: The rate limiter selection logic does not respect the
`ratelimit.backend` configuration setting. Currently, the match expression at
line 385 only checks if `cfg.ratelimit.redis` is present, which means the redis
backend can be used even when `backend: memory` is configured. Modify the
branching logic to check both the `cfg.ratelimit.backend` value AND the presence
of `cfg.ratelimit.redis`. Only instantiate the redis-backed limiter when
`backend` is explicitly set to redis and the `cfg.ratelimit.redis` block is
present; otherwise, use the memory backend with `Limiter::new()`. This ensures
the user's backend configuration choice is honored regardless of whether a redis
block exists in the config.
In `@tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts`:
- Around line 140-147: The afterAll hook in the ratelimit-cluster-e2e.test.ts
file calls deletePrefix without checking whether the etcd infrastructure is
available, which causes test suite failures when etcd is unavailable even though
tests correctly skip via ctx.skip() when infra is down. Guard the deletePrefix
call (at line 146 in the afterAll hook and also at line 195 in another afterAll
hook) behind a readiness check to ensure cleanup only runs if etcd is actually
available, preventing teardown failures from failing the entire suite when
infrastructure is unavailable.
🪄 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: CHILL

Plan: Pro

Run ID: 47eb2a68-4b09-4fc1-8b62-5de06116e6e4

📥 Commits

Reviewing files that changed from the base of the PR and between ca2542e and 5102235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-ratelimit/Cargo.toml
  • crates/aisix-ratelimit/src/lib.rs
  • crates/aisix-ratelimit/src/limiter.rs
  • crates/aisix-ratelimit/src/store/local.rs
  • crates/aisix-ratelimit/src/store/mod.rs
  • crates/aisix-ratelimit/src/store/redis.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • docs/configuration/rate-limits.md
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts

Comment threadcrates/aisix-core/src/config.rs Outdated
Comment threadcrates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment threadcrates/aisix-server/src/main.rs
Comment threadtests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
… robustness
- main.rs: select the rate-limit store on `ratelimit.backend`, not on
`ratelimit.redis` presence, so a stray redis block under
`backend: memory` no longer silently activates Redis.
- config: reject `concurrency_ttl_secs: 0` for the redis backend (a zero
TTL prunes a slot in the same second it is taken, disabling concurrency
limiting). + unit test.
- redis integration test: poll (bounded) for the detached ZREM instead of
a fixed 200ms sleep.
- cluster e2e: guard the afterAll deletePrefix behind the readiness flag
so teardown doesn't fail when infra is unavailable.
@jarvis9443
jarvis9443 merged commit dbdcf20 into mainJun 15, 2026
10 checks passed
@jarvis9443
jarvis9443 deleted the feat/cluster-ratelimit-798 branch June 15, 2026 04:11
moonming added a commit that referenced this pull request Jun 15, 2026
)
Unbreaks main: #606 merged onto a main with #607's MultiReservation API change (semantic conflict, no textual conflict). Ports the streaming-ensemble reservation code to the new API and fixes the latent un-awaited commit_tokens (panel tokens were never billed on streaming error exits). Verified green locally + CI.
Sign up for freeto 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.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ratelimit): cluster-level rate limiting via shared Redis - #607

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798
Jun 15, 2026
Merged

feat(ratelimit): cluster-level rate limiting via shared Redis#607
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rate-limit counters live in per-process memory (FixedWindowCounter in a DashMap), so each DP replica counts only the traffic it personally served. A cluster of N replicas behind a load balancer therefore enforces N× every configured limit — a key capped at rpm: 1 gets one request per replica per minute. The reporter saw exactly this: instance :3000 returns 429 while :3001 still serves the same key.

Approach

Introduce a pluggable RateStore backend behind Limiter:

  • LocalStore — the historical per-process fixed-window counters, unchanged. Stays the default, so single-node and dev deployments behave exactly as before (all prior limiter unit tests pass against it verbatim).
  • RedisStore — shares the counters across every replica through one Redis, so the whole cluster enforces one global window. Counter math mirrors LocalStore/FixedWindowCounter (wall-clock-aligned windows now - now % window) so swapping memory ↔ redis doesn't change observable limits, only whether the count is shared.

RedisStore details:

  • One Lua per bucket does the atomic, all-or-nothing acquire: concurrency gate + token check-only + request check-and-increment. redis.call('TIME') is used for now so window boundaries are identical across replicas regardless of host clock skew.
  • Keys are namespaced aisix:rl: and hash-tagged {<bucket>} so all of a bucket's keys co-locate on one Redis Cluster slot (the per-bucket Lua stays atomic). The Redis may be the same instance used for the response cache.
  • All dimensions are shared, including concurrency: it's tracked as a ZSET semaphore (member → score=now) where acquire prunes entries older than concurrency_ttl_secs before counting, so a slot held by a crashed/hung replica is reclaimed within the TTL. (A window-TTL counter would mishandle long streaming responses — the same reason StreamConcurrencyGuard exists.)
  • On any Redis error the store fails open to per-replica in-memory counting (logged once): traffic keeps flowing during an outage and global enforcement resumes when Redis recovers.

The enforcement path (pre_commit/commit_tokens/peek) is now async; concurrency release stays a synchronous Drop (the Redis backend detaches a ZREM, bounded by the TTL prune). All LLM endpoints share the quota::enforce / enforce_rate_limit helpers, so every endpoint inherits the fix uniformly.

Configuration

New ratelimit block, defaulting to memory (current behaviour):

ratelimit:
backend: "redis"# memory | redisredis:
url: "redis://host:6379"concurrency_ttl_secs: 300

Reachable by env on managed/containerized deployments: AISIX_RATELIMIT__BACKEND=redis, AISIX_RATELIMIT__REDIS__URL=.... backend: redis without a redis block is rejected at boot.

Behaviour changes

  • Default deployments are unchanged (backend: memory).
  • Multi-replica deployments that opt into backend: redis now enforce limits cluster-wide instead of per replica.

Tests

  • Rust integration (crates/aisix-ratelimit/tests/redis_integration.rs, gated on RATELIMIT_TEST_REDIS_URL, CI provisions redis:7-alpine): two RedisStore instances share rpm/rps/tpm/concurrency counters; rps window rollover; stale concurrency slot reclaimed after TTL.
  • DP e2e (tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts): spins up two real aisix binaries on one shared etcd + one shared Redis with an rpm: 1 key — request to replica A → 200, request to replica B → 429 + Retry-After (the exact issue repro). A contrast suite with the default memory backend shows both replicas serve the request (the per-replica bug).
  • All existing limiter unit tests pass against LocalStore; new config-validation unit tests cover the ratelimit.redis rules.

Fixes api7/AISIX-Cloud#798

Summary by CodeRabbit

  • New Features

    • Added a ratelimit configuration block with selectable backend (memory default or redis) for cluster-wide rate limiting across multiple replicas.
    • Redis backend supports shared concurrency enforcement via concurrency_ttl_secs, enabling cross-replica in-flight slot reclamation.
    • When Redis is unavailable, rate limiting degrades gracefully to per-replica in-memory behavior while logging the incident.
  • Documentation

    • Expanded rate-limit documentation with “single node vs cluster” storage guidance and multi-replica behavior examples.
  • Tests

    • Added Redis-backed and multi-replica E2E coverage for rate-limit correctness and retry behavior.

Fixes api7/AISIX-Cloud#788

Rate-limit counters lived in per-process memory, so an N-replica DP
cluster enforced N× every configured limit (a key capped at rpm:1 got
one request per replica per minute). Add a Redis-backed shared store so
the whole cluster enforces one global window.
- Introduce a `RateStore` backend behind `Limiter`: `LocalStore`
(unchanged in-memory default) and `RedisStore` (Lua check-and-increment
over wall-clock-aligned fixed windows, `redis.call('TIME')` for
cross-replica window consistency, hash-tagged keys for Cluster slot
co-location). All dimensions are shared — rps/rpm/rph/rpd/tpm/tpd plus
concurrency, tracked as a crash-safe ZSET semaphore reclaimed after
`concurrency_ttl_secs`. On a Redis outage the store fails open to
per-replica counting.
- Make the enforcement path async (`pre_commit`/`commit_tokens`/`peek`);
concurrency release stays a sync `Drop` (Redis detaches a ZREM).
- New `ratelimit` config block (`backend: memory|redis`, `redis`,
`concurrency_ttl_secs`), enabled via env on managed deployments.
Tests: Rust integration (gated on RATELIMIT_TEST_REDIS_URL) for shared
rpm/rps/tpm/concurrency + TTL reclaim; DP e2e spins two real binaries on
one Redis (A→200, B→429) plus a memory-backend regression (both 200).
Fixesapi7/AISIX-Cloud#798
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5736522-d2aa-4a9c-9eec-8edab704f19a

📥 Commits

Reviewing files that changed from the base of the PR and between 5102235 and 3dc567c.

📒 Files selected for processing (4)
  • crates/aisix-core/src/config.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/aisix-server/src/main.rs
  • crates/aisix-core/src/config.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
  • crates/aisix-ratelimit/tests/redis_integration.rs

📝 Walkthrough

Walkthrough

Adds a pluggable RateStore trait with LocalStore (in-process fixed-window) and RedisStore (Lua-script atomic, fail-open) backends. Refactors Limiter, Reservation, MultiReservation, and StreamConcurrencyGuard from sync clock-generic to async store-backed. Migrates proxy quota, chat, and embeddings paths to async reservation APIs. Wires conditional Redis initialization at server startup via a new ratelimit config block with validation and environment variable support.

Changes

Cluster-wide rate limiting

Layer / File(s)Summary
RateLimitConfig types, validation, and re-exports
crates/aisix-core/src/config.rs, crates/aisix-core/src/lib.rs, config.example.yaml, config.managed.yaml
Adds RateLimitConfig struct and RateLimitBackend enum to Config, a boot-time check requiring ratelimit.redis block and non-zero concurrency_ttl_secs when backend is redis, four config tests, public re-exports of the new types, and documented config file examples.
RateStore trait and LocalStore backend
crates/aisix-ratelimit/src/store/mod.rs, crates/aisix-ratelimit/src/store/local.rs
Defines the RateStore trait (async acquire/commit/peek, sync release/add_tokens), shared window-dimension constants and helpers (request_dims, token_dims), and the in-process LocalStore with per-key DashMap state, layered request-window rollback-on-reject acquire logic, and peek.
RedisStore Lua scripts and RateStore implementation
crates/aisix-ratelimit/Cargo.toml, crates/aisix-ratelimit/src/store/redis.rs
Adds async-trait, redis, uuid dependencies; embeds four Lua scripts (ACQUIRE, COMMIT, ADD_TOKENS, PEEK) for atomic Redis operations with ZSET concurrency semaphore; implements RedisStore with fail-open fallback to LocalStore, fire-and-forget release/add_tokens via spawned tasks, and connect/with_conc_ttl constructors.
Limiter refactored to async store-backed API
crates/aisix-ratelimit/src/lib.rs, crates/aisix-ratelimit/src/limiter.rs
Replaces clock-generic in-memory Limiter<C> with a store-backed Limiter backed by Arc<dyn RateStore>; makes pre_commit and peek async; reworks Reservation, MultiReservation, and StreamConcurrencyGuard to own store references and release concurrency on drop; converts all unit tests to #[tokio::test].
Proxy quota, chat, and embeddings async migration
crates/aisix-proxy/src/quota.rs, crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/embeddings.rs
Converts reserve_layers, enforce, and enforce_rate_limit to async fn and awaits all pre_commit calls; updates all commit_tokens, peek, into_stream_hold, and dispatch_ensemble call sites in chat and embeddings to use the async API without passing limiter to hold().
Server startup conditional Redis initialization
crates/aisix-server/src/main.rs, .github/workflows/ci.yml
Replaces unconditional Limiter::new() with a branch that connects RedisStore when cfg.ratelimit.backend is Redis, applies concurrency_ttl_secs, and wraps it in Limiter::with_store; adds RATELIMIT_TEST_REDIS_URL to CI environment for integration tests.
Redis integration tests, E2E cluster tests, and docs
crates/aisix-ratelimit/tests/redis_integration.rs, tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts, docs/configuration/rate-limits.md
Adds RedisStore integration tests for shared RPM/RPS/token/concurrency/TTL across two store instances; adds E2E cluster tests asserting cross-replica 429 enforcement with Redis backend and independent per-replica 200s with in-memory backend; documents counter storage backends and operator guidance for multi-replica deployments.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ProxyHandler as chat.rs / embeddings.rs
participant Quota as quota.rs enforce_rate_limit
participant Limiter
participant RateStore as LocalStore or RedisStore
participant Redis
Client->>ProxyHandler: POST /v1/chat/completions
ProxyHandler->>Quota: enforce_rate_limit(state, auth, model_rl).await
Quota->>Limiter: pre_commit(key, limits).await
Limiter->>RateStore: acquire(key, limits, member).await
alt Redis backend
RateStore->>Redis: EVALSHA ACQUIRE_LUA
Redis-->>RateStore: ok / rate_limit_error
end
RateStore-->>Limiter: Ok(()) or RateLimitError
Limiter-->>Quota: Reservation
Quota-->>ProxyHandler: MultiReservation
alt streaming
ProxyHandler->>ProxyHandler: into_stream_hold() → StreamConcurrencyGuard
Note over ProxyHandler: concurrency held for stream lifetime
ProxyHandler->>RateStore: add_tokens_post_stream(key, tokens)
Note over ProxyHandler: guard dropped → release(key, member)
else non-streaming / cache-hit
ProxyHandler->>ProxyHandler: reservation.commit_tokens(tokens).await
ProxyHandler->>RateStore: commit(key, tokens, member).await
alt Redis backend
RateStore->>Redis: EVALSHA COMMIT_LUA
end
end
ProxyHandler-->>Client: 200 OK or 429 Too Many Requests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • api7/ai-gateway#481: Both PRs modify MultiReservation::into_stream_hold and StreamConcurrencyGuard to hold concurrency permits for the lifetime of streaming responses, with this PR restructuring the guard ownership model to support the async store backend.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately reflects the main change: introducing cluster-level rate limiting via shared Redis backend. It is concise, specific, and clearly describes the primary objective.
Linked Issues check✅ PassedAll requirements from issue #798 are met: the PR implements cluster-level rate limiting via shared Redis, with LocalStore as default for backward compatibility, atomic enforcement via Lua scripts, ZSET-based concurrency tracking, fail-open behavior on Redis errors, configuration via ratelimit.backend, and comprehensive testing including e2e multi-replica verification.
Out of Scope Changes check✅ PassedAll changes are directly scoped to implementing cluster-level rate limiting: RateStore abstraction, LocalStore and RedisStore implementations, async refactoring of enforcement paths, configuration additions, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@crates/aisix-core/src/config.rs`:
- Around line 704-708: Add a validation check in the same config validation
block that checks the Redis backend to also enforce that concurrency_ttl_secs
must be a positive value (greater than 0) when the Redis backend is selected.
When Redis semaphore reclamation is enabled, a concurrency_ttl_secs value of 0
would immediately reclaim active slots and disable concurrency limiting, so add
a condition to return a BootstrapError::Config with a clear message if
concurrency_ttl_secs is 0 or negative while using the Redis backend, similar to
the existing Redis backend validation pattern.
In `@crates/aisix-ratelimit/tests/redis_integration.rs`:
- Around line 161-167: Replace the hardcoded 200ms sleep after a.release(&key,
"a-1") with bounded polling that repeatedly attempts the b.acquire(&key,
&limits, "b-2") operation until it succeeds or a reasonable timeout is reached.
Instead of assuming a fixed propagation delay, use a loop with
tokio::time::timeout or a similar mechanism to poll for the actual condition
(slot availability) rather than sleeping, which makes the test robust to varying
CI executor speeds.
In `@crates/aisix-server/src/main.rs`:
- Around line 379-401: The rate limiter selection logic does not respect the
`ratelimit.backend` configuration setting. Currently, the match expression at
line 385 only checks if `cfg.ratelimit.redis` is present, which means the redis
backend can be used even when `backend: memory` is configured. Modify the
branching logic to check both the `cfg.ratelimit.backend` value AND the presence
of `cfg.ratelimit.redis`. Only instantiate the redis-backed limiter when
`backend` is explicitly set to redis and the `cfg.ratelimit.redis` block is
present; otherwise, use the memory backend with `Limiter::new()`. This ensures
the user's backend configuration choice is honored regardless of whether a redis
block exists in the config.
In `@tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts`:
- Around line 140-147: The afterAll hook in the ratelimit-cluster-e2e.test.ts
file calls deletePrefix without checking whether the etcd infrastructure is
available, which causes test suite failures when etcd is unavailable even though
tests correctly skip via ctx.skip() when infra is down. Guard the deletePrefix
call (at line 146 in the afterAll hook and also at line 195 in another afterAll
hook) behind a readiness check to ensure cleanup only runs if etcd is actually
available, preventing teardown failures from failing the entire suite when
infrastructure is unavailable.
🪄 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: CHILL

Plan: Pro

Run ID: 47eb2a68-4b09-4fc1-8b62-5de06116e6e4

📥 Commits

Reviewing files that changed from the base of the PR and between ca2542e and 5102235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-ratelimit/Cargo.toml
  • crates/aisix-ratelimit/src/lib.rs
  • crates/aisix-ratelimit/src/limiter.rs
  • crates/aisix-ratelimit/src/store/local.rs
  • crates/aisix-ratelimit/src/store/mod.rs
  • crates/aisix-ratelimit/src/store/redis.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • docs/configuration/rate-limits.md
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts

Comment threadcrates/aisix-core/src/config.rs Outdated
Comment threadcrates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment threadcrates/aisix-server/src/main.rs
Comment threadtests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
… robustness
- main.rs: select the rate-limit store on `ratelimit.backend`, not on
`ratelimit.redis` presence, so a stray redis block under
`backend: memory` no longer silently activates Redis.
- config: reject `concurrency_ttl_secs: 0` for the redis backend (a zero
TTL prunes a slot in the same second it is taken, disabling concurrency
limiting). + unit test.
- redis integration test: poll (bounded) for the detached ZREM instead of
a fixed 200ms sleep.
- cluster e2e: guard the afterAll deletePrefix behind the readiness flag
so teardown doesn't fail when infra is unavailable.
@jarvis9443
jarvis9443 merged commit dbdcf20 into mainJun 15, 2026
10 checks passed
@jarvis9443
jarvis9443 deleted the feat/cluster-ratelimit-798 branch June 15, 2026 04:11
moonming added a commit that referenced this pull request Jun 15, 2026
)
Unbreaks main: #606 merged onto a main with #607's MultiReservation API change (semantic conflict, no textual conflict). Ports the streaming-ensemble reservation code to the new API and fixes the latent un-awaited commit_tokens (panel tokens were never billed on streaming error exits). Verified green locally + CI.
Sign up for freeto 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.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ratelimit): cluster-level rate limiting via shared Redis - #607

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798
Jun 15, 2026
Merged

feat(ratelimit): cluster-level rate limiting via shared Redis#607
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rate-limit counters live in per-process memory (FixedWindowCounter in a DashMap), so each DP replica counts only the traffic it personally served. A cluster of N replicas behind a load balancer therefore enforces N× every configured limit — a key capped at rpm: 1 gets one request per replica per minute. The reporter saw exactly this: instance :3000 returns 429 while :3001 still serves the same key.

Approach

Introduce a pluggable RateStore backend behind Limiter:

  • LocalStore — the historical per-process fixed-window counters, unchanged. Stays the default, so single-node and dev deployments behave exactly as before (all prior limiter unit tests pass against it verbatim).
  • RedisStore — shares the counters across every replica through one Redis, so the whole cluster enforces one global window. Counter math mirrors LocalStore/FixedWindowCounter (wall-clock-aligned windows now - now % window) so swapping memory ↔ redis doesn't change observable limits, only whether the count is shared.

RedisStore details:

  • One Lua per bucket does the atomic, all-or-nothing acquire: concurrency gate + token check-only + request check-and-increment. redis.call('TIME') is used for now so window boundaries are identical across replicas regardless of host clock skew.
  • Keys are namespaced aisix:rl: and hash-tagged {<bucket>} so all of a bucket's keys co-locate on one Redis Cluster slot (the per-bucket Lua stays atomic). The Redis may be the same instance used for the response cache.
  • All dimensions are shared, including concurrency: it's tracked as a ZSET semaphore (member → score=now) where acquire prunes entries older than concurrency_ttl_secs before counting, so a slot held by a crashed/hung replica is reclaimed within the TTL. (A window-TTL counter would mishandle long streaming responses — the same reason StreamConcurrencyGuard exists.)
  • On any Redis error the store fails open to per-replica in-memory counting (logged once): traffic keeps flowing during an outage and global enforcement resumes when Redis recovers.

The enforcement path (pre_commit/commit_tokens/peek) is now async; concurrency release stays a synchronous Drop (the Redis backend detaches a ZREM, bounded by the TTL prune). All LLM endpoints share the quota::enforce / enforce_rate_limit helpers, so every endpoint inherits the fix uniformly.

Configuration

New ratelimit block, defaulting to memory (current behaviour):

ratelimit:
backend: "redis"# memory | redisredis:
url: "redis://host:6379"concurrency_ttl_secs: 300

Reachable by env on managed/containerized deployments: AISIX_RATELIMIT__BACKEND=redis, AISIX_RATELIMIT__REDIS__URL=.... backend: redis without a redis block is rejected at boot.

Behaviour changes

  • Default deployments are unchanged (backend: memory).
  • Multi-replica deployments that opt into backend: redis now enforce limits cluster-wide instead of per replica.

Tests

  • Rust integration (crates/aisix-ratelimit/tests/redis_integration.rs, gated on RATELIMIT_TEST_REDIS_URL, CI provisions redis:7-alpine): two RedisStore instances share rpm/rps/tpm/concurrency counters; rps window rollover; stale concurrency slot reclaimed after TTL.
  • DP e2e (tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts): spins up two real aisix binaries on one shared etcd + one shared Redis with an rpm: 1 key — request to replica A → 200, request to replica B → 429 + Retry-After (the exact issue repro). A contrast suite with the default memory backend shows both replicas serve the request (the per-replica bug).
  • All existing limiter unit tests pass against LocalStore; new config-validation unit tests cover the ratelimit.redis rules.

Fixes api7/AISIX-Cloud#798

Summary by CodeRabbit

  • New Features

    • Added a ratelimit configuration block with selectable backend (memory default or redis) for cluster-wide rate limiting across multiple replicas.
    • Redis backend supports shared concurrency enforcement via concurrency_ttl_secs, enabling cross-replica in-flight slot reclamation.
    • When Redis is unavailable, rate limiting degrades gracefully to per-replica in-memory behavior while logging the incident.
  • Documentation

    • Expanded rate-limit documentation with “single node vs cluster” storage guidance and multi-replica behavior examples.
  • Tests

    • Added Redis-backed and multi-replica E2E coverage for rate-limit correctness and retry behavior.

Fixes api7/AISIX-Cloud#788

Rate-limit counters lived in per-process memory, so an N-replica DP
cluster enforced N× every configured limit (a key capped at rpm:1 got
one request per replica per minute). Add a Redis-backed shared store so
the whole cluster enforces one global window.
- Introduce a `RateStore` backend behind `Limiter`: `LocalStore`
(unchanged in-memory default) and `RedisStore` (Lua check-and-increment
over wall-clock-aligned fixed windows, `redis.call('TIME')` for
cross-replica window consistency, hash-tagged keys for Cluster slot
co-location). All dimensions are shared — rps/rpm/rph/rpd/tpm/tpd plus
concurrency, tracked as a crash-safe ZSET semaphore reclaimed after
`concurrency_ttl_secs`. On a Redis outage the store fails open to
per-replica counting.
- Make the enforcement path async (`pre_commit`/`commit_tokens`/`peek`);
concurrency release stays a sync `Drop` (Redis detaches a ZREM).
- New `ratelimit` config block (`backend: memory|redis`, `redis`,
`concurrency_ttl_secs`), enabled via env on managed deployments.
Tests: Rust integration (gated on RATELIMIT_TEST_REDIS_URL) for shared
rpm/rps/tpm/concurrency + TTL reclaim; DP e2e spins two real binaries on
one Redis (A→200, B→429) plus a memory-backend regression (both 200).
Fixesapi7/AISIX-Cloud#798
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5736522-d2aa-4a9c-9eec-8edab704f19a

📥 Commits

Reviewing files that changed from the base of the PR and between 5102235 and 3dc567c.

📒 Files selected for processing (4)
  • crates/aisix-core/src/config.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/aisix-server/src/main.rs
  • crates/aisix-core/src/config.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
  • crates/aisix-ratelimit/tests/redis_integration.rs

📝 Walkthrough

Walkthrough

Adds a pluggable RateStore trait with LocalStore (in-process fixed-window) and RedisStore (Lua-script atomic, fail-open) backends. Refactors Limiter, Reservation, MultiReservation, and StreamConcurrencyGuard from sync clock-generic to async store-backed. Migrates proxy quota, chat, and embeddings paths to async reservation APIs. Wires conditional Redis initialization at server startup via a new ratelimit config block with validation and environment variable support.

Changes

Cluster-wide rate limiting

Layer / File(s)Summary
RateLimitConfig types, validation, and re-exports
crates/aisix-core/src/config.rs, crates/aisix-core/src/lib.rs, config.example.yaml, config.managed.yaml
Adds RateLimitConfig struct and RateLimitBackend enum to Config, a boot-time check requiring ratelimit.redis block and non-zero concurrency_ttl_secs when backend is redis, four config tests, public re-exports of the new types, and documented config file examples.
RateStore trait and LocalStore backend
crates/aisix-ratelimit/src/store/mod.rs, crates/aisix-ratelimit/src/store/local.rs
Defines the RateStore trait (async acquire/commit/peek, sync release/add_tokens), shared window-dimension constants and helpers (request_dims, token_dims), and the in-process LocalStore with per-key DashMap state, layered request-window rollback-on-reject acquire logic, and peek.
RedisStore Lua scripts and RateStore implementation
crates/aisix-ratelimit/Cargo.toml, crates/aisix-ratelimit/src/store/redis.rs
Adds async-trait, redis, uuid dependencies; embeds four Lua scripts (ACQUIRE, COMMIT, ADD_TOKENS, PEEK) for atomic Redis operations with ZSET concurrency semaphore; implements RedisStore with fail-open fallback to LocalStore, fire-and-forget release/add_tokens via spawned tasks, and connect/with_conc_ttl constructors.
Limiter refactored to async store-backed API
crates/aisix-ratelimit/src/lib.rs, crates/aisix-ratelimit/src/limiter.rs
Replaces clock-generic in-memory Limiter<C> with a store-backed Limiter backed by Arc<dyn RateStore>; makes pre_commit and peek async; reworks Reservation, MultiReservation, and StreamConcurrencyGuard to own store references and release concurrency on drop; converts all unit tests to #[tokio::test].
Proxy quota, chat, and embeddings async migration
crates/aisix-proxy/src/quota.rs, crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/embeddings.rs
Converts reserve_layers, enforce, and enforce_rate_limit to async fn and awaits all pre_commit calls; updates all commit_tokens, peek, into_stream_hold, and dispatch_ensemble call sites in chat and embeddings to use the async API without passing limiter to hold().
Server startup conditional Redis initialization
crates/aisix-server/src/main.rs, .github/workflows/ci.yml
Replaces unconditional Limiter::new() with a branch that connects RedisStore when cfg.ratelimit.backend is Redis, applies concurrency_ttl_secs, and wraps it in Limiter::with_store; adds RATELIMIT_TEST_REDIS_URL to CI environment for integration tests.
Redis integration tests, E2E cluster tests, and docs
crates/aisix-ratelimit/tests/redis_integration.rs, tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts, docs/configuration/rate-limits.md
Adds RedisStore integration tests for shared RPM/RPS/token/concurrency/TTL across two store instances; adds E2E cluster tests asserting cross-replica 429 enforcement with Redis backend and independent per-replica 200s with in-memory backend; documents counter storage backends and operator guidance for multi-replica deployments.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ProxyHandler as chat.rs / embeddings.rs
participant Quota as quota.rs enforce_rate_limit
participant Limiter
participant RateStore as LocalStore or RedisStore
participant Redis
Client->>ProxyHandler: POST /v1/chat/completions
ProxyHandler->>Quota: enforce_rate_limit(state, auth, model_rl).await
Quota->>Limiter: pre_commit(key, limits).await
Limiter->>RateStore: acquire(key, limits, member).await
alt Redis backend
RateStore->>Redis: EVALSHA ACQUIRE_LUA
Redis-->>RateStore: ok / rate_limit_error
end
RateStore-->>Limiter: Ok(()) or RateLimitError
Limiter-->>Quota: Reservation
Quota-->>ProxyHandler: MultiReservation
alt streaming
ProxyHandler->>ProxyHandler: into_stream_hold() → StreamConcurrencyGuard
Note over ProxyHandler: concurrency held for stream lifetime
ProxyHandler->>RateStore: add_tokens_post_stream(key, tokens)
Note over ProxyHandler: guard dropped → release(key, member)
else non-streaming / cache-hit
ProxyHandler->>ProxyHandler: reservation.commit_tokens(tokens).await
ProxyHandler->>RateStore: commit(key, tokens, member).await
alt Redis backend
RateStore->>Redis: EVALSHA COMMIT_LUA
end
end
ProxyHandler-->>Client: 200 OK or 429 Too Many Requests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • api7/ai-gateway#481: Both PRs modify MultiReservation::into_stream_hold and StreamConcurrencyGuard to hold concurrency permits for the lifetime of streaming responses, with this PR restructuring the guard ownership model to support the async store backend.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately reflects the main change: introducing cluster-level rate limiting via shared Redis backend. It is concise, specific, and clearly describes the primary objective.
Linked Issues check✅ PassedAll requirements from issue #798 are met: the PR implements cluster-level rate limiting via shared Redis, with LocalStore as default for backward compatibility, atomic enforcement via Lua scripts, ZSET-based concurrency tracking, fail-open behavior on Redis errors, configuration via ratelimit.backend, and comprehensive testing including e2e multi-replica verification.
Out of Scope Changes check✅ PassedAll changes are directly scoped to implementing cluster-level rate limiting: RateStore abstraction, LocalStore and RedisStore implementations, async refactoring of enforcement paths, configuration additions, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@crates/aisix-core/src/config.rs`:
- Around line 704-708: Add a validation check in the same config validation
block that checks the Redis backend to also enforce that concurrency_ttl_secs
must be a positive value (greater than 0) when the Redis backend is selected.
When Redis semaphore reclamation is enabled, a concurrency_ttl_secs value of 0
would immediately reclaim active slots and disable concurrency limiting, so add
a condition to return a BootstrapError::Config with a clear message if
concurrency_ttl_secs is 0 or negative while using the Redis backend, similar to
the existing Redis backend validation pattern.
In `@crates/aisix-ratelimit/tests/redis_integration.rs`:
- Around line 161-167: Replace the hardcoded 200ms sleep after a.release(&key,
"a-1") with bounded polling that repeatedly attempts the b.acquire(&key,
&limits, "b-2") operation until it succeeds or a reasonable timeout is reached.
Instead of assuming a fixed propagation delay, use a loop with
tokio::time::timeout or a similar mechanism to poll for the actual condition
(slot availability) rather than sleeping, which makes the test robust to varying
CI executor speeds.
In `@crates/aisix-server/src/main.rs`:
- Around line 379-401: The rate limiter selection logic does not respect the
`ratelimit.backend` configuration setting. Currently, the match expression at
line 385 only checks if `cfg.ratelimit.redis` is present, which means the redis
backend can be used even when `backend: memory` is configured. Modify the
branching logic to check both the `cfg.ratelimit.backend` value AND the presence
of `cfg.ratelimit.redis`. Only instantiate the redis-backed limiter when
`backend` is explicitly set to redis and the `cfg.ratelimit.redis` block is
present; otherwise, use the memory backend with `Limiter::new()`. This ensures
the user's backend configuration choice is honored regardless of whether a redis
block exists in the config.
In `@tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts`:
- Around line 140-147: The afterAll hook in the ratelimit-cluster-e2e.test.ts
file calls deletePrefix without checking whether the etcd infrastructure is
available, which causes test suite failures when etcd is unavailable even though
tests correctly skip via ctx.skip() when infra is down. Guard the deletePrefix
call (at line 146 in the afterAll hook and also at line 195 in another afterAll
hook) behind a readiness check to ensure cleanup only runs if etcd is actually
available, preventing teardown failures from failing the entire suite when
infrastructure is unavailable.
🪄 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: CHILL

Plan: Pro

Run ID: 47eb2a68-4b09-4fc1-8b62-5de06116e6e4

📥 Commits

Reviewing files that changed from the base of the PR and between ca2542e and 5102235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-ratelimit/Cargo.toml
  • crates/aisix-ratelimit/src/lib.rs
  • crates/aisix-ratelimit/src/limiter.rs
  • crates/aisix-ratelimit/src/store/local.rs
  • crates/aisix-ratelimit/src/store/mod.rs
  • crates/aisix-ratelimit/src/store/redis.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • docs/configuration/rate-limits.md
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts

Comment threadcrates/aisix-core/src/config.rs Outdated
Comment threadcrates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment threadcrates/aisix-server/src/main.rs
Comment threadtests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
… robustness
- main.rs: select the rate-limit store on `ratelimit.backend`, not on
`ratelimit.redis` presence, so a stray redis block under
`backend: memory` no longer silently activates Redis.
- config: reject `concurrency_ttl_secs: 0` for the redis backend (a zero
TTL prunes a slot in the same second it is taken, disabling concurrency
limiting). + unit test.
- redis integration test: poll (bounded) for the detached ZREM instead of
a fixed 200ms sleep.
- cluster e2e: guard the afterAll deletePrefix behind the readiness flag
so teardown doesn't fail when infra is unavailable.
@jarvis9443
jarvis9443 merged commit dbdcf20 into mainJun 15, 2026
10 checks passed
@jarvis9443
jarvis9443 deleted the feat/cluster-ratelimit-798 branch June 15, 2026 04:11
moonming added a commit that referenced this pull request Jun 15, 2026
)
Unbreaks main: #606 merged onto a main with #607's MultiReservation API change (semantic conflict, no textual conflict). Ports the streaming-ensemble reservation code to the new API and fixes the latent un-awaited commit_tokens (panel tokens were never billed on streaming error exits). Verified green locally + CI.
Sign up for freeto 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.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(ratelimit): cluster-level rate limiting via shared Redis - #607

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798
Jun 15, 2026
Merged

feat(ratelimit): cluster-level rate limiting via shared Redis#607
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rate-limit counters live in per-process memory (FixedWindowCounter in a DashMap), so each DP replica counts only the traffic it personally served. A cluster of N replicas behind a load balancer therefore enforces N× every configured limit — a key capped at rpm: 1 gets one request per replica per minute. The reporter saw exactly this: instance :3000 returns 429 while :3001 still serves the same key.

Approach

Introduce a pluggable RateStore backend behind Limiter:

  • LocalStore — the historical per-process fixed-window counters, unchanged. Stays the default, so single-node and dev deployments behave exactly as before (all prior limiter unit tests pass against it verbatim).
  • RedisStore — shares the counters across every replica through one Redis, so the whole cluster enforces one global window. Counter math mirrors LocalStore/FixedWindowCounter (wall-clock-aligned windows now - now % window) so swapping memory ↔ redis doesn't change observable limits, only whether the count is shared.

RedisStore details:

  • One Lua per bucket does the atomic, all-or-nothing acquire: concurrency gate + token check-only + request check-and-increment. redis.call('TIME') is used for now so window boundaries are identical across replicas regardless of host clock skew.
  • Keys are namespaced aisix:rl: and hash-tagged {<bucket>} so all of a bucket's keys co-locate on one Redis Cluster slot (the per-bucket Lua stays atomic). The Redis may be the same instance used for the response cache.
  • All dimensions are shared, including concurrency: it's tracked as a ZSET semaphore (member → score=now) where acquire prunes entries older than concurrency_ttl_secs before counting, so a slot held by a crashed/hung replica is reclaimed within the TTL. (A window-TTL counter would mishandle long streaming responses — the same reason StreamConcurrencyGuard exists.)
  • On any Redis error the store fails open to per-replica in-memory counting (logged once): traffic keeps flowing during an outage and global enforcement resumes when Redis recovers.

The enforcement path (pre_commit/commit_tokens/peek) is now async; concurrency release stays a synchronous Drop (the Redis backend detaches a ZREM, bounded by the TTL prune). All LLM endpoints share the quota::enforce / enforce_rate_limit helpers, so every endpoint inherits the fix uniformly.

Configuration

New ratelimit block, defaulting to memory (current behaviour):

ratelimit:
backend: "redis"# memory | redisredis:
url: "redis://host:6379"concurrency_ttl_secs: 300

Reachable by env on managed/containerized deployments: AISIX_RATELIMIT__BACKEND=redis, AISIX_RATELIMIT__REDIS__URL=.... backend: redis without a redis block is rejected at boot.

Behaviour changes

  • Default deployments are unchanged (backend: memory).
  • Multi-replica deployments that opt into backend: redis now enforce limits cluster-wide instead of per replica.

Tests

  • Rust integration (crates/aisix-ratelimit/tests/redis_integration.rs, gated on RATELIMIT_TEST_REDIS_URL, CI provisions redis:7-alpine): two RedisStore instances share rpm/rps/tpm/concurrency counters; rps window rollover; stale concurrency slot reclaimed after TTL.
  • DP e2e (tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts): spins up two real aisix binaries on one shared etcd + one shared Redis with an rpm: 1 key — request to replica A → 200, request to replica B → 429 + Retry-After (the exact issue repro). A contrast suite with the default memory backend shows both replicas serve the request (the per-replica bug).
  • All existing limiter unit tests pass against LocalStore; new config-validation unit tests cover the ratelimit.redis rules.

Fixes api7/AISIX-Cloud#798

Summary by CodeRabbit

  • New Features

    • Added a ratelimit configuration block with selectable backend (memory default or redis) for cluster-wide rate limiting across multiple replicas.
    • Redis backend supports shared concurrency enforcement via concurrency_ttl_secs, enabling cross-replica in-flight slot reclamation.
    • When Redis is unavailable, rate limiting degrades gracefully to per-replica in-memory behavior while logging the incident.
  • Documentation

    • Expanded rate-limit documentation with “single node vs cluster” storage guidance and multi-replica behavior examples.
  • Tests

    • Added Redis-backed and multi-replica E2E coverage for rate-limit correctness and retry behavior.

Fixes api7/AISIX-Cloud#788

Rate-limit counters lived in per-process memory, so an N-replica DP
cluster enforced N× every configured limit (a key capped at rpm:1 got
one request per replica per minute). Add a Redis-backed shared store so
the whole cluster enforces one global window.
- Introduce a `RateStore` backend behind `Limiter`: `LocalStore`
(unchanged in-memory default) and `RedisStore` (Lua check-and-increment
over wall-clock-aligned fixed windows, `redis.call('TIME')` for
cross-replica window consistency, hash-tagged keys for Cluster slot
co-location). All dimensions are shared — rps/rpm/rph/rpd/tpm/tpd plus
concurrency, tracked as a crash-safe ZSET semaphore reclaimed after
`concurrency_ttl_secs`. On a Redis outage the store fails open to
per-replica counting.
- Make the enforcement path async (`pre_commit`/`commit_tokens`/`peek`);
concurrency release stays a sync `Drop` (Redis detaches a ZREM).
- New `ratelimit` config block (`backend: memory|redis`, `redis`,
`concurrency_ttl_secs`), enabled via env on managed deployments.
Tests: Rust integration (gated on RATELIMIT_TEST_REDIS_URL) for shared
rpm/rps/tpm/concurrency + TTL reclaim; DP e2e spins two real binaries on
one Redis (A→200, B→429) plus a memory-backend regression (both 200).
Fixesapi7/AISIX-Cloud#798
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5736522-d2aa-4a9c-9eec-8edab704f19a

📥 Commits

Reviewing files that changed from the base of the PR and between 5102235 and 3dc567c.

📒 Files selected for processing (4)
  • crates/aisix-core/src/config.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/aisix-server/src/main.rs
  • crates/aisix-core/src/config.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
  • crates/aisix-ratelimit/tests/redis_integration.rs

📝 Walkthrough

Walkthrough

Adds a pluggable RateStore trait with LocalStore (in-process fixed-window) and RedisStore (Lua-script atomic, fail-open) backends. Refactors Limiter, Reservation, MultiReservation, and StreamConcurrencyGuard from sync clock-generic to async store-backed. Migrates proxy quota, chat, and embeddings paths to async reservation APIs. Wires conditional Redis initialization at server startup via a new ratelimit config block with validation and environment variable support.

Changes

Cluster-wide rate limiting

Layer / File(s)Summary
RateLimitConfig types, validation, and re-exports
crates/aisix-core/src/config.rs, crates/aisix-core/src/lib.rs, config.example.yaml, config.managed.yaml
Adds RateLimitConfig struct and RateLimitBackend enum to Config, a boot-time check requiring ratelimit.redis block and non-zero concurrency_ttl_secs when backend is redis, four config tests, public re-exports of the new types, and documented config file examples.
RateStore trait and LocalStore backend
crates/aisix-ratelimit/src/store/mod.rs, crates/aisix-ratelimit/src/store/local.rs
Defines the RateStore trait (async acquire/commit/peek, sync release/add_tokens), shared window-dimension constants and helpers (request_dims, token_dims), and the in-process LocalStore with per-key DashMap state, layered request-window rollback-on-reject acquire logic, and peek.
RedisStore Lua scripts and RateStore implementation
crates/aisix-ratelimit/Cargo.toml, crates/aisix-ratelimit/src/store/redis.rs
Adds async-trait, redis, uuid dependencies; embeds four Lua scripts (ACQUIRE, COMMIT, ADD_TOKENS, PEEK) for atomic Redis operations with ZSET concurrency semaphore; implements RedisStore with fail-open fallback to LocalStore, fire-and-forget release/add_tokens via spawned tasks, and connect/with_conc_ttl constructors.
Limiter refactored to async store-backed API
crates/aisix-ratelimit/src/lib.rs, crates/aisix-ratelimit/src/limiter.rs
Replaces clock-generic in-memory Limiter<C> with a store-backed Limiter backed by Arc<dyn RateStore>; makes pre_commit and peek async; reworks Reservation, MultiReservation, and StreamConcurrencyGuard to own store references and release concurrency on drop; converts all unit tests to #[tokio::test].
Proxy quota, chat, and embeddings async migration
crates/aisix-proxy/src/quota.rs, crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/embeddings.rs
Converts reserve_layers, enforce, and enforce_rate_limit to async fn and awaits all pre_commit calls; updates all commit_tokens, peek, into_stream_hold, and dispatch_ensemble call sites in chat and embeddings to use the async API without passing limiter to hold().
Server startup conditional Redis initialization
crates/aisix-server/src/main.rs, .github/workflows/ci.yml
Replaces unconditional Limiter::new() with a branch that connects RedisStore when cfg.ratelimit.backend is Redis, applies concurrency_ttl_secs, and wraps it in Limiter::with_store; adds RATELIMIT_TEST_REDIS_URL to CI environment for integration tests.
Redis integration tests, E2E cluster tests, and docs
crates/aisix-ratelimit/tests/redis_integration.rs, tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts, docs/configuration/rate-limits.md
Adds RedisStore integration tests for shared RPM/RPS/token/concurrency/TTL across two store instances; adds E2E cluster tests asserting cross-replica 429 enforcement with Redis backend and independent per-replica 200s with in-memory backend; documents counter storage backends and operator guidance for multi-replica deployments.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ProxyHandler as chat.rs / embeddings.rs
participant Quota as quota.rs enforce_rate_limit
participant Limiter
participant RateStore as LocalStore or RedisStore
participant Redis
Client->>ProxyHandler: POST /v1/chat/completions
ProxyHandler->>Quota: enforce_rate_limit(state, auth, model_rl).await
Quota->>Limiter: pre_commit(key, limits).await
Limiter->>RateStore: acquire(key, limits, member).await
alt Redis backend
RateStore->>Redis: EVALSHA ACQUIRE_LUA
Redis-->>RateStore: ok / rate_limit_error
end
RateStore-->>Limiter: Ok(()) or RateLimitError
Limiter-->>Quota: Reservation
Quota-->>ProxyHandler: MultiReservation
alt streaming
ProxyHandler->>ProxyHandler: into_stream_hold() → StreamConcurrencyGuard
Note over ProxyHandler: concurrency held for stream lifetime
ProxyHandler->>RateStore: add_tokens_post_stream(key, tokens)
Note over ProxyHandler: guard dropped → release(key, member)
else non-streaming / cache-hit
ProxyHandler->>ProxyHandler: reservation.commit_tokens(tokens).await
ProxyHandler->>RateStore: commit(key, tokens, member).await
alt Redis backend
RateStore->>Redis: EVALSHA COMMIT_LUA
end
end
ProxyHandler-->>Client: 200 OK or 429 Too Many Requests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • api7/ai-gateway#481: Both PRs modify MultiReservation::into_stream_hold and StreamConcurrencyGuard to hold concurrency permits for the lifetime of streaming responses, with this PR restructuring the guard ownership model to support the async store backend.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately reflects the main change: introducing cluster-level rate limiting via shared Redis backend. It is concise, specific, and clearly describes the primary objective.
Linked Issues check✅ PassedAll requirements from issue #798 are met: the PR implements cluster-level rate limiting via shared Redis, with LocalStore as default for backward compatibility, atomic enforcement via Lua scripts, ZSET-based concurrency tracking, fail-open behavior on Redis errors, configuration via ratelimit.backend, and comprehensive testing including e2e multi-replica verification.
Out of Scope Changes check✅ PassedAll changes are directly scoped to implementing cluster-level rate limiting: RateStore abstraction, LocalStore and RedisStore implementations, async refactoring of enforcement paths, configuration additions, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@crates/aisix-core/src/config.rs`:
- Around line 704-708: Add a validation check in the same config validation
block that checks the Redis backend to also enforce that concurrency_ttl_secs
must be a positive value (greater than 0) when the Redis backend is selected.
When Redis semaphore reclamation is enabled, a concurrency_ttl_secs value of 0
would immediately reclaim active slots and disable concurrency limiting, so add
a condition to return a BootstrapError::Config with a clear message if
concurrency_ttl_secs is 0 or negative while using the Redis backend, similar to
the existing Redis backend validation pattern.
In `@crates/aisix-ratelimit/tests/redis_integration.rs`:
- Around line 161-167: Replace the hardcoded 200ms sleep after a.release(&key,
"a-1") with bounded polling that repeatedly attempts the b.acquire(&key,
&limits, "b-2") operation until it succeeds or a reasonable timeout is reached.
Instead of assuming a fixed propagation delay, use a loop with
tokio::time::timeout or a similar mechanism to poll for the actual condition
(slot availability) rather than sleeping, which makes the test robust to varying
CI executor speeds.
In `@crates/aisix-server/src/main.rs`:
- Around line 379-401: The rate limiter selection logic does not respect the
`ratelimit.backend` configuration setting. Currently, the match expression at
line 385 only checks if `cfg.ratelimit.redis` is present, which means the redis
backend can be used even when `backend: memory` is configured. Modify the
branching logic to check both the `cfg.ratelimit.backend` value AND the presence
of `cfg.ratelimit.redis`. Only instantiate the redis-backed limiter when
`backend` is explicitly set to redis and the `cfg.ratelimit.redis` block is
present; otherwise, use the memory backend with `Limiter::new()`. This ensures
the user's backend configuration choice is honored regardless of whether a redis
block exists in the config.
In `@tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts`:
- Around line 140-147: The afterAll hook in the ratelimit-cluster-e2e.test.ts
file calls deletePrefix without checking whether the etcd infrastructure is
available, which causes test suite failures when etcd is unavailable even though
tests correctly skip via ctx.skip() when infra is down. Guard the deletePrefix
call (at line 146 in the afterAll hook and also at line 195 in another afterAll
hook) behind a readiness check to ensure cleanup only runs if etcd is actually
available, preventing teardown failures from failing the entire suite when
infrastructure is unavailable.
🪄 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: CHILL

Plan: Pro

Run ID: 47eb2a68-4b09-4fc1-8b62-5de06116e6e4

📥 Commits

Reviewing files that changed from the base of the PR and between ca2542e and 5102235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-ratelimit/Cargo.toml
  • crates/aisix-ratelimit/src/lib.rs
  • crates/aisix-ratelimit/src/limiter.rs
  • crates/aisix-ratelimit/src/store/local.rs
  • crates/aisix-ratelimit/src/store/mod.rs
  • crates/aisix-ratelimit/src/store/redis.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • docs/configuration/rate-limits.md
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts

Comment threadcrates/aisix-core/src/config.rs Outdated
Comment threadcrates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment threadcrates/aisix-server/src/main.rs
Comment threadtests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
… robustness
- main.rs: select the rate-limit store on `ratelimit.backend`, not on
`ratelimit.redis` presence, so a stray redis block under
`backend: memory` no longer silently activates Redis.
- config: reject `concurrency_ttl_secs: 0` for the redis backend (a zero
TTL prunes a slot in the same second it is taken, disabling concurrency
limiting). + unit test.
- redis integration test: poll (bounded) for the detached ZREM instead of
a fixed 200ms sleep.
- cluster e2e: guard the afterAll deletePrefix behind the readiness flag
so teardown doesn't fail when infra is unavailable.
@jarvis9443
jarvis9443 merged commit dbdcf20 into mainJun 15, 2026
10 checks passed
@jarvis9443
jarvis9443 deleted the feat/cluster-ratelimit-798 branch June 15, 2026 04:11
moonming added a commit that referenced this pull request Jun 15, 2026
)
Unbreaks main: #606 merged onto a main with #607's MultiReservation API change (semantic conflict, no textual conflict). Ports the streaming-ensemble reservation code to the new API and fixes the latent un-awaited commit_tokens (panel tokens were never billed on streaming error exits). Verified green locally + CI.
Sign up for freeto 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.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ratelimit): cluster-level rate limiting via shared Redis - #607

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798
Jun 15, 2026
Merged

feat(ratelimit): cluster-level rate limiting via shared Redis#607
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rate-limit counters live in per-process memory (FixedWindowCounter in a DashMap), so each DP replica counts only the traffic it personally served. A cluster of N replicas behind a load balancer therefore enforces N× every configured limit — a key capped at rpm: 1 gets one request per replica per minute. The reporter saw exactly this: instance :3000 returns 429 while :3001 still serves the same key.

Approach

Introduce a pluggable RateStore backend behind Limiter:

  • LocalStore — the historical per-process fixed-window counters, unchanged. Stays the default, so single-node and dev deployments behave exactly as before (all prior limiter unit tests pass against it verbatim).
  • RedisStore — shares the counters across every replica through one Redis, so the whole cluster enforces one global window. Counter math mirrors LocalStore/FixedWindowCounter (wall-clock-aligned windows now - now % window) so swapping memory ↔ redis doesn't change observable limits, only whether the count is shared.

RedisStore details:

  • One Lua per bucket does the atomic, all-or-nothing acquire: concurrency gate + token check-only + request check-and-increment. redis.call('TIME') is used for now so window boundaries are identical across replicas regardless of host clock skew.
  • Keys are namespaced aisix:rl: and hash-tagged {<bucket>} so all of a bucket's keys co-locate on one Redis Cluster slot (the per-bucket Lua stays atomic). The Redis may be the same instance used for the response cache.
  • All dimensions are shared, including concurrency: it's tracked as a ZSET semaphore (member → score=now) where acquire prunes entries older than concurrency_ttl_secs before counting, so a slot held by a crashed/hung replica is reclaimed within the TTL. (A window-TTL counter would mishandle long streaming responses — the same reason StreamConcurrencyGuard exists.)
  • On any Redis error the store fails open to per-replica in-memory counting (logged once): traffic keeps flowing during an outage and global enforcement resumes when Redis recovers.

The enforcement path (pre_commit/commit_tokens/peek) is now async; concurrency release stays a synchronous Drop (the Redis backend detaches a ZREM, bounded by the TTL prune). All LLM endpoints share the quota::enforce / enforce_rate_limit helpers, so every endpoint inherits the fix uniformly.

Configuration

New ratelimit block, defaulting to memory (current behaviour):

ratelimit:
backend: "redis"# memory | redisredis:
url: "redis://host:6379"concurrency_ttl_secs: 300

Reachable by env on managed/containerized deployments: AISIX_RATELIMIT__BACKEND=redis, AISIX_RATELIMIT__REDIS__URL=.... backend: redis without a redis block is rejected at boot.

Behaviour changes

  • Default deployments are unchanged (backend: memory).
  • Multi-replica deployments that opt into backend: redis now enforce limits cluster-wide instead of per replica.

Tests

  • Rust integration (crates/aisix-ratelimit/tests/redis_integration.rs, gated on RATELIMIT_TEST_REDIS_URL, CI provisions redis:7-alpine): two RedisStore instances share rpm/rps/tpm/concurrency counters; rps window rollover; stale concurrency slot reclaimed after TTL.
  • DP e2e (tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts): spins up two real aisix binaries on one shared etcd + one shared Redis with an rpm: 1 key — request to replica A → 200, request to replica B → 429 + Retry-After (the exact issue repro). A contrast suite with the default memory backend shows both replicas serve the request (the per-replica bug).
  • All existing limiter unit tests pass against LocalStore; new config-validation unit tests cover the ratelimit.redis rules.

Fixes api7/AISIX-Cloud#798

Summary by CodeRabbit

  • New Features

    • Added a ratelimit configuration block with selectable backend (memory default or redis) for cluster-wide rate limiting across multiple replicas.
    • Redis backend supports shared concurrency enforcement via concurrency_ttl_secs, enabling cross-replica in-flight slot reclamation.
    • When Redis is unavailable, rate limiting degrades gracefully to per-replica in-memory behavior while logging the incident.
  • Documentation

    • Expanded rate-limit documentation with “single node vs cluster” storage guidance and multi-replica behavior examples.
  • Tests

    • Added Redis-backed and multi-replica E2E coverage for rate-limit correctness and retry behavior.

Fixes api7/AISIX-Cloud#788

Rate-limit counters lived in per-process memory, so an N-replica DP
cluster enforced N× every configured limit (a key capped at rpm:1 got
one request per replica per minute). Add a Redis-backed shared store so
the whole cluster enforces one global window.
- Introduce a `RateStore` backend behind `Limiter`: `LocalStore`
(unchanged in-memory default) and `RedisStore` (Lua check-and-increment
over wall-clock-aligned fixed windows, `redis.call('TIME')` for
cross-replica window consistency, hash-tagged keys for Cluster slot
co-location). All dimensions are shared — rps/rpm/rph/rpd/tpm/tpd plus
concurrency, tracked as a crash-safe ZSET semaphore reclaimed after
`concurrency_ttl_secs`. On a Redis outage the store fails open to
per-replica counting.
- Make the enforcement path async (`pre_commit`/`commit_tokens`/`peek`);
concurrency release stays a sync `Drop` (Redis detaches a ZREM).
- New `ratelimit` config block (`backend: memory|redis`, `redis`,
`concurrency_ttl_secs`), enabled via env on managed deployments.
Tests: Rust integration (gated on RATELIMIT_TEST_REDIS_URL) for shared
rpm/rps/tpm/concurrency + TTL reclaim; DP e2e spins two real binaries on
one Redis (A→200, B→429) plus a memory-backend regression (both 200).
Fixesapi7/AISIX-Cloud#798
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5736522-d2aa-4a9c-9eec-8edab704f19a

📥 Commits

Reviewing files that changed from the base of the PR and between 5102235 and 3dc567c.

📒 Files selected for processing (4)
  • crates/aisix-core/src/config.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/aisix-server/src/main.rs
  • crates/aisix-core/src/config.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
  • crates/aisix-ratelimit/tests/redis_integration.rs

📝 Walkthrough

Walkthrough

Adds a pluggable RateStore trait with LocalStore (in-process fixed-window) and RedisStore (Lua-script atomic, fail-open) backends. Refactors Limiter, Reservation, MultiReservation, and StreamConcurrencyGuard from sync clock-generic to async store-backed. Migrates proxy quota, chat, and embeddings paths to async reservation APIs. Wires conditional Redis initialization at server startup via a new ratelimit config block with validation and environment variable support.

Changes

Cluster-wide rate limiting

Layer / File(s)Summary
RateLimitConfig types, validation, and re-exports
crates/aisix-core/src/config.rs, crates/aisix-core/src/lib.rs, config.example.yaml, config.managed.yaml
Adds RateLimitConfig struct and RateLimitBackend enum to Config, a boot-time check requiring ratelimit.redis block and non-zero concurrency_ttl_secs when backend is redis, four config tests, public re-exports of the new types, and documented config file examples.
RateStore trait and LocalStore backend
crates/aisix-ratelimit/src/store/mod.rs, crates/aisix-ratelimit/src/store/local.rs
Defines the RateStore trait (async acquire/commit/peek, sync release/add_tokens), shared window-dimension constants and helpers (request_dims, token_dims), and the in-process LocalStore with per-key DashMap state, layered request-window rollback-on-reject acquire logic, and peek.
RedisStore Lua scripts and RateStore implementation
crates/aisix-ratelimit/Cargo.toml, crates/aisix-ratelimit/src/store/redis.rs
Adds async-trait, redis, uuid dependencies; embeds four Lua scripts (ACQUIRE, COMMIT, ADD_TOKENS, PEEK) for atomic Redis operations with ZSET concurrency semaphore; implements RedisStore with fail-open fallback to LocalStore, fire-and-forget release/add_tokens via spawned tasks, and connect/with_conc_ttl constructors.
Limiter refactored to async store-backed API
crates/aisix-ratelimit/src/lib.rs, crates/aisix-ratelimit/src/limiter.rs
Replaces clock-generic in-memory Limiter<C> with a store-backed Limiter backed by Arc<dyn RateStore>; makes pre_commit and peek async; reworks Reservation, MultiReservation, and StreamConcurrencyGuard to own store references and release concurrency on drop; converts all unit tests to #[tokio::test].
Proxy quota, chat, and embeddings async migration
crates/aisix-proxy/src/quota.rs, crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/embeddings.rs
Converts reserve_layers, enforce, and enforce_rate_limit to async fn and awaits all pre_commit calls; updates all commit_tokens, peek, into_stream_hold, and dispatch_ensemble call sites in chat and embeddings to use the async API without passing limiter to hold().
Server startup conditional Redis initialization
crates/aisix-server/src/main.rs, .github/workflows/ci.yml
Replaces unconditional Limiter::new() with a branch that connects RedisStore when cfg.ratelimit.backend is Redis, applies concurrency_ttl_secs, and wraps it in Limiter::with_store; adds RATELIMIT_TEST_REDIS_URL to CI environment for integration tests.
Redis integration tests, E2E cluster tests, and docs
crates/aisix-ratelimit/tests/redis_integration.rs, tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts, docs/configuration/rate-limits.md
Adds RedisStore integration tests for shared RPM/RPS/token/concurrency/TTL across two store instances; adds E2E cluster tests asserting cross-replica 429 enforcement with Redis backend and independent per-replica 200s with in-memory backend; documents counter storage backends and operator guidance for multi-replica deployments.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ProxyHandler as chat.rs / embeddings.rs
participant Quota as quota.rs enforce_rate_limit
participant Limiter
participant RateStore as LocalStore or RedisStore
participant Redis
Client->>ProxyHandler: POST /v1/chat/completions
ProxyHandler->>Quota: enforce_rate_limit(state, auth, model_rl).await
Quota->>Limiter: pre_commit(key, limits).await
Limiter->>RateStore: acquire(key, limits, member).await
alt Redis backend
RateStore->>Redis: EVALSHA ACQUIRE_LUA
Redis-->>RateStore: ok / rate_limit_error
end
RateStore-->>Limiter: Ok(()) or RateLimitError
Limiter-->>Quota: Reservation
Quota-->>ProxyHandler: MultiReservation
alt streaming
ProxyHandler->>ProxyHandler: into_stream_hold() → StreamConcurrencyGuard
Note over ProxyHandler: concurrency held for stream lifetime
ProxyHandler->>RateStore: add_tokens_post_stream(key, tokens)
Note over ProxyHandler: guard dropped → release(key, member)
else non-streaming / cache-hit
ProxyHandler->>ProxyHandler: reservation.commit_tokens(tokens).await
ProxyHandler->>RateStore: commit(key, tokens, member).await
alt Redis backend
RateStore->>Redis: EVALSHA COMMIT_LUA
end
end
ProxyHandler-->>Client: 200 OK or 429 Too Many Requests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • api7/ai-gateway#481: Both PRs modify MultiReservation::into_stream_hold and StreamConcurrencyGuard to hold concurrency permits for the lifetime of streaming responses, with this PR restructuring the guard ownership model to support the async store backend.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately reflects the main change: introducing cluster-level rate limiting via shared Redis backend. It is concise, specific, and clearly describes the primary objective.
Linked Issues check✅ PassedAll requirements from issue #798 are met: the PR implements cluster-level rate limiting via shared Redis, with LocalStore as default for backward compatibility, atomic enforcement via Lua scripts, ZSET-based concurrency tracking, fail-open behavior on Redis errors, configuration via ratelimit.backend, and comprehensive testing including e2e multi-replica verification.
Out of Scope Changes check✅ PassedAll changes are directly scoped to implementing cluster-level rate limiting: RateStore abstraction, LocalStore and RedisStore implementations, async refactoring of enforcement paths, configuration additions, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@crates/aisix-core/src/config.rs`:
- Around line 704-708: Add a validation check in the same config validation
block that checks the Redis backend to also enforce that concurrency_ttl_secs
must be a positive value (greater than 0) when the Redis backend is selected.
When Redis semaphore reclamation is enabled, a concurrency_ttl_secs value of 0
would immediately reclaim active slots and disable concurrency limiting, so add
a condition to return a BootstrapError::Config with a clear message if
concurrency_ttl_secs is 0 or negative while using the Redis backend, similar to
the existing Redis backend validation pattern.
In `@crates/aisix-ratelimit/tests/redis_integration.rs`:
- Around line 161-167: Replace the hardcoded 200ms sleep after a.release(&key,
"a-1") with bounded polling that repeatedly attempts the b.acquire(&key,
&limits, "b-2") operation until it succeeds or a reasonable timeout is reached.
Instead of assuming a fixed propagation delay, use a loop with
tokio::time::timeout or a similar mechanism to poll for the actual condition
(slot availability) rather than sleeping, which makes the test robust to varying
CI executor speeds.
In `@crates/aisix-server/src/main.rs`:
- Around line 379-401: The rate limiter selection logic does not respect the
`ratelimit.backend` configuration setting. Currently, the match expression at
line 385 only checks if `cfg.ratelimit.redis` is present, which means the redis
backend can be used even when `backend: memory` is configured. Modify the
branching logic to check both the `cfg.ratelimit.backend` value AND the presence
of `cfg.ratelimit.redis`. Only instantiate the redis-backed limiter when
`backend` is explicitly set to redis and the `cfg.ratelimit.redis` block is
present; otherwise, use the memory backend with `Limiter::new()`. This ensures
the user's backend configuration choice is honored regardless of whether a redis
block exists in the config.
In `@tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts`:
- Around line 140-147: The afterAll hook in the ratelimit-cluster-e2e.test.ts
file calls deletePrefix without checking whether the etcd infrastructure is
available, which causes test suite failures when etcd is unavailable even though
tests correctly skip via ctx.skip() when infra is down. Guard the deletePrefix
call (at line 146 in the afterAll hook and also at line 195 in another afterAll
hook) behind a readiness check to ensure cleanup only runs if etcd is actually
available, preventing teardown failures from failing the entire suite when
infrastructure is unavailable.
🪄 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: CHILL

Plan: Pro

Run ID: 47eb2a68-4b09-4fc1-8b62-5de06116e6e4

📥 Commits

Reviewing files that changed from the base of the PR and between ca2542e and 5102235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-ratelimit/Cargo.toml
  • crates/aisix-ratelimit/src/lib.rs
  • crates/aisix-ratelimit/src/limiter.rs
  • crates/aisix-ratelimit/src/store/local.rs
  • crates/aisix-ratelimit/src/store/mod.rs
  • crates/aisix-ratelimit/src/store/redis.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • docs/configuration/rate-limits.md
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts

Comment threadcrates/aisix-core/src/config.rs Outdated
Comment threadcrates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment threadcrates/aisix-server/src/main.rs
Comment threadtests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
… robustness
- main.rs: select the rate-limit store on `ratelimit.backend`, not on
`ratelimit.redis` presence, so a stray redis block under
`backend: memory` no longer silently activates Redis.
- config: reject `concurrency_ttl_secs: 0` for the redis backend (a zero
TTL prunes a slot in the same second it is taken, disabling concurrency
limiting). + unit test.
- redis integration test: poll (bounded) for the detached ZREM instead of
a fixed 200ms sleep.
- cluster e2e: guard the afterAll deletePrefix behind the readiness flag
so teardown doesn't fail when infra is unavailable.
@jarvis9443
jarvis9443 merged commit dbdcf20 into mainJun 15, 2026
10 checks passed
@jarvis9443
jarvis9443 deleted the feat/cluster-ratelimit-798 branch June 15, 2026 04:11
moonming added a commit that referenced this pull request Jun 15, 2026
)
Unbreaks main: #606 merged onto a main with #607's MultiReservation API change (semantic conflict, no textual conflict). Ports the streaming-ensemble reservation code to the new API and fixes the latent un-awaited commit_tokens (panel tokens were never billed on streaming error exits). Verified green locally + CI.
Sign up for freeto 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.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ratelimit): cluster-level rate limiting via shared Redis - #607

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798
Jun 15, 2026
Merged

feat(ratelimit): cluster-level rate limiting via shared Redis#607
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rate-limit counters live in per-process memory (FixedWindowCounter in a DashMap), so each DP replica counts only the traffic it personally served. A cluster of N replicas behind a load balancer therefore enforces N× every configured limit — a key capped at rpm: 1 gets one request per replica per minute. The reporter saw exactly this: instance :3000 returns 429 while :3001 still serves the same key.

Approach

Introduce a pluggable RateStore backend behind Limiter:

  • LocalStore — the historical per-process fixed-window counters, unchanged. Stays the default, so single-node and dev deployments behave exactly as before (all prior limiter unit tests pass against it verbatim).
  • RedisStore — shares the counters across every replica through one Redis, so the whole cluster enforces one global window. Counter math mirrors LocalStore/FixedWindowCounter (wall-clock-aligned windows now - now % window) so swapping memory ↔ redis doesn't change observable limits, only whether the count is shared.

RedisStore details:

  • One Lua per bucket does the atomic, all-or-nothing acquire: concurrency gate + token check-only + request check-and-increment. redis.call('TIME') is used for now so window boundaries are identical across replicas regardless of host clock skew.
  • Keys are namespaced aisix:rl: and hash-tagged {<bucket>} so all of a bucket's keys co-locate on one Redis Cluster slot (the per-bucket Lua stays atomic). The Redis may be the same instance used for the response cache.
  • All dimensions are shared, including concurrency: it's tracked as a ZSET semaphore (member → score=now) where acquire prunes entries older than concurrency_ttl_secs before counting, so a slot held by a crashed/hung replica is reclaimed within the TTL. (A window-TTL counter would mishandle long streaming responses — the same reason StreamConcurrencyGuard exists.)
  • On any Redis error the store fails open to per-replica in-memory counting (logged once): traffic keeps flowing during an outage and global enforcement resumes when Redis recovers.

The enforcement path (pre_commit/commit_tokens/peek) is now async; concurrency release stays a synchronous Drop (the Redis backend detaches a ZREM, bounded by the TTL prune). All LLM endpoints share the quota::enforce / enforce_rate_limit helpers, so every endpoint inherits the fix uniformly.

Configuration

New ratelimit block, defaulting to memory (current behaviour):

ratelimit:
backend: "redis"# memory | redisredis:
url: "redis://host:6379"concurrency_ttl_secs: 300

Reachable by env on managed/containerized deployments: AISIX_RATELIMIT__BACKEND=redis, AISIX_RATELIMIT__REDIS__URL=.... backend: redis without a redis block is rejected at boot.

Behaviour changes

  • Default deployments are unchanged (backend: memory).
  • Multi-replica deployments that opt into backend: redis now enforce limits cluster-wide instead of per replica.

Tests

  • Rust integration (crates/aisix-ratelimit/tests/redis_integration.rs, gated on RATELIMIT_TEST_REDIS_URL, CI provisions redis:7-alpine): two RedisStore instances share rpm/rps/tpm/concurrency counters; rps window rollover; stale concurrency slot reclaimed after TTL.
  • DP e2e (tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts): spins up two real aisix binaries on one shared etcd + one shared Redis with an rpm: 1 key — request to replica A → 200, request to replica B → 429 + Retry-After (the exact issue repro). A contrast suite with the default memory backend shows both replicas serve the request (the per-replica bug).
  • All existing limiter unit tests pass against LocalStore; new config-validation unit tests cover the ratelimit.redis rules.

Fixes api7/AISIX-Cloud#798

Summary by CodeRabbit

  • New Features

    • Added a ratelimit configuration block with selectable backend (memory default or redis) for cluster-wide rate limiting across multiple replicas.
    • Redis backend supports shared concurrency enforcement via concurrency_ttl_secs, enabling cross-replica in-flight slot reclamation.
    • When Redis is unavailable, rate limiting degrades gracefully to per-replica in-memory behavior while logging the incident.
  • Documentation

    • Expanded rate-limit documentation with “single node vs cluster” storage guidance and multi-replica behavior examples.
  • Tests

    • Added Redis-backed and multi-replica E2E coverage for rate-limit correctness and retry behavior.

Fixes api7/AISIX-Cloud#788

Rate-limit counters lived in per-process memory, so an N-replica DP
cluster enforced N× every configured limit (a key capped at rpm:1 got
one request per replica per minute). Add a Redis-backed shared store so
the whole cluster enforces one global window.
- Introduce a `RateStore` backend behind `Limiter`: `LocalStore`
(unchanged in-memory default) and `RedisStore` (Lua check-and-increment
over wall-clock-aligned fixed windows, `redis.call('TIME')` for
cross-replica window consistency, hash-tagged keys for Cluster slot
co-location). All dimensions are shared — rps/rpm/rph/rpd/tpm/tpd plus
concurrency, tracked as a crash-safe ZSET semaphore reclaimed after
`concurrency_ttl_secs`. On a Redis outage the store fails open to
per-replica counting.
- Make the enforcement path async (`pre_commit`/`commit_tokens`/`peek`);
concurrency release stays a sync `Drop` (Redis detaches a ZREM).
- New `ratelimit` config block (`backend: memory|redis`, `redis`,
`concurrency_ttl_secs`), enabled via env on managed deployments.
Tests: Rust integration (gated on RATELIMIT_TEST_REDIS_URL) for shared
rpm/rps/tpm/concurrency + TTL reclaim; DP e2e spins two real binaries on
one Redis (A→200, B→429) plus a memory-backend regression (both 200).
Fixesapi7/AISIX-Cloud#798
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5736522-d2aa-4a9c-9eec-8edab704f19a

📥 Commits

Reviewing files that changed from the base of the PR and between 5102235 and 3dc567c.

📒 Files selected for processing (4)
  • crates/aisix-core/src/config.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/aisix-server/src/main.rs
  • crates/aisix-core/src/config.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
  • crates/aisix-ratelimit/tests/redis_integration.rs

📝 Walkthrough

Walkthrough

Adds a pluggable RateStore trait with LocalStore (in-process fixed-window) and RedisStore (Lua-script atomic, fail-open) backends. Refactors Limiter, Reservation, MultiReservation, and StreamConcurrencyGuard from sync clock-generic to async store-backed. Migrates proxy quota, chat, and embeddings paths to async reservation APIs. Wires conditional Redis initialization at server startup via a new ratelimit config block with validation and environment variable support.

Changes

Cluster-wide rate limiting

Layer / File(s)Summary
RateLimitConfig types, validation, and re-exports
crates/aisix-core/src/config.rs, crates/aisix-core/src/lib.rs, config.example.yaml, config.managed.yaml
Adds RateLimitConfig struct and RateLimitBackend enum to Config, a boot-time check requiring ratelimit.redis block and non-zero concurrency_ttl_secs when backend is redis, four config tests, public re-exports of the new types, and documented config file examples.
RateStore trait and LocalStore backend
crates/aisix-ratelimit/src/store/mod.rs, crates/aisix-ratelimit/src/store/local.rs
Defines the RateStore trait (async acquire/commit/peek, sync release/add_tokens), shared window-dimension constants and helpers (request_dims, token_dims), and the in-process LocalStore with per-key DashMap state, layered request-window rollback-on-reject acquire logic, and peek.
RedisStore Lua scripts and RateStore implementation
crates/aisix-ratelimit/Cargo.toml, crates/aisix-ratelimit/src/store/redis.rs
Adds async-trait, redis, uuid dependencies; embeds four Lua scripts (ACQUIRE, COMMIT, ADD_TOKENS, PEEK) for atomic Redis operations with ZSET concurrency semaphore; implements RedisStore with fail-open fallback to LocalStore, fire-and-forget release/add_tokens via spawned tasks, and connect/with_conc_ttl constructors.
Limiter refactored to async store-backed API
crates/aisix-ratelimit/src/lib.rs, crates/aisix-ratelimit/src/limiter.rs
Replaces clock-generic in-memory Limiter<C> with a store-backed Limiter backed by Arc<dyn RateStore>; makes pre_commit and peek async; reworks Reservation, MultiReservation, and StreamConcurrencyGuard to own store references and release concurrency on drop; converts all unit tests to #[tokio::test].
Proxy quota, chat, and embeddings async migration
crates/aisix-proxy/src/quota.rs, crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/embeddings.rs
Converts reserve_layers, enforce, and enforce_rate_limit to async fn and awaits all pre_commit calls; updates all commit_tokens, peek, into_stream_hold, and dispatch_ensemble call sites in chat and embeddings to use the async API without passing limiter to hold().
Server startup conditional Redis initialization
crates/aisix-server/src/main.rs, .github/workflows/ci.yml
Replaces unconditional Limiter::new() with a branch that connects RedisStore when cfg.ratelimit.backend is Redis, applies concurrency_ttl_secs, and wraps it in Limiter::with_store; adds RATELIMIT_TEST_REDIS_URL to CI environment for integration tests.
Redis integration tests, E2E cluster tests, and docs
crates/aisix-ratelimit/tests/redis_integration.rs, tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts, docs/configuration/rate-limits.md
Adds RedisStore integration tests for shared RPM/RPS/token/concurrency/TTL across two store instances; adds E2E cluster tests asserting cross-replica 429 enforcement with Redis backend and independent per-replica 200s with in-memory backend; documents counter storage backends and operator guidance for multi-replica deployments.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ProxyHandler as chat.rs / embeddings.rs
participant Quota as quota.rs enforce_rate_limit
participant Limiter
participant RateStore as LocalStore or RedisStore
participant Redis
Client->>ProxyHandler: POST /v1/chat/completions
ProxyHandler->>Quota: enforce_rate_limit(state, auth, model_rl).await
Quota->>Limiter: pre_commit(key, limits).await
Limiter->>RateStore: acquire(key, limits, member).await
alt Redis backend
RateStore->>Redis: EVALSHA ACQUIRE_LUA
Redis-->>RateStore: ok / rate_limit_error
end
RateStore-->>Limiter: Ok(()) or RateLimitError
Limiter-->>Quota: Reservation
Quota-->>ProxyHandler: MultiReservation
alt streaming
ProxyHandler->>ProxyHandler: into_stream_hold() → StreamConcurrencyGuard
Note over ProxyHandler: concurrency held for stream lifetime
ProxyHandler->>RateStore: add_tokens_post_stream(key, tokens)
Note over ProxyHandler: guard dropped → release(key, member)
else non-streaming / cache-hit
ProxyHandler->>ProxyHandler: reservation.commit_tokens(tokens).await
ProxyHandler->>RateStore: commit(key, tokens, member).await
alt Redis backend
RateStore->>Redis: EVALSHA COMMIT_LUA
end
end
ProxyHandler-->>Client: 200 OK or 429 Too Many Requests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • api7/ai-gateway#481: Both PRs modify MultiReservation::into_stream_hold and StreamConcurrencyGuard to hold concurrency permits for the lifetime of streaming responses, with this PR restructuring the guard ownership model to support the async store backend.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately reflects the main change: introducing cluster-level rate limiting via shared Redis backend. It is concise, specific, and clearly describes the primary objective.
Linked Issues check✅ PassedAll requirements from issue #798 are met: the PR implements cluster-level rate limiting via shared Redis, with LocalStore as default for backward compatibility, atomic enforcement via Lua scripts, ZSET-based concurrency tracking, fail-open behavior on Redis errors, configuration via ratelimit.backend, and comprehensive testing including e2e multi-replica verification.
Out of Scope Changes check✅ PassedAll changes are directly scoped to implementing cluster-level rate limiting: RateStore abstraction, LocalStore and RedisStore implementations, async refactoring of enforcement paths, configuration additions, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@crates/aisix-core/src/config.rs`:
- Around line 704-708: Add a validation check in the same config validation
block that checks the Redis backend to also enforce that concurrency_ttl_secs
must be a positive value (greater than 0) when the Redis backend is selected.
When Redis semaphore reclamation is enabled, a concurrency_ttl_secs value of 0
would immediately reclaim active slots and disable concurrency limiting, so add
a condition to return a BootstrapError::Config with a clear message if
concurrency_ttl_secs is 0 or negative while using the Redis backend, similar to
the existing Redis backend validation pattern.
In `@crates/aisix-ratelimit/tests/redis_integration.rs`:
- Around line 161-167: Replace the hardcoded 200ms sleep after a.release(&key,
"a-1") with bounded polling that repeatedly attempts the b.acquire(&key,
&limits, "b-2") operation until it succeeds or a reasonable timeout is reached.
Instead of assuming a fixed propagation delay, use a loop with
tokio::time::timeout or a similar mechanism to poll for the actual condition
(slot availability) rather than sleeping, which makes the test robust to varying
CI executor speeds.
In `@crates/aisix-server/src/main.rs`:
- Around line 379-401: The rate limiter selection logic does not respect the
`ratelimit.backend` configuration setting. Currently, the match expression at
line 385 only checks if `cfg.ratelimit.redis` is present, which means the redis
backend can be used even when `backend: memory` is configured. Modify the
branching logic to check both the `cfg.ratelimit.backend` value AND the presence
of `cfg.ratelimit.redis`. Only instantiate the redis-backed limiter when
`backend` is explicitly set to redis and the `cfg.ratelimit.redis` block is
present; otherwise, use the memory backend with `Limiter::new()`. This ensures
the user's backend configuration choice is honored regardless of whether a redis
block exists in the config.
In `@tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts`:
- Around line 140-147: The afterAll hook in the ratelimit-cluster-e2e.test.ts
file calls deletePrefix without checking whether the etcd infrastructure is
available, which causes test suite failures when etcd is unavailable even though
tests correctly skip via ctx.skip() when infra is down. Guard the deletePrefix
call (at line 146 in the afterAll hook and also at line 195 in another afterAll
hook) behind a readiness check to ensure cleanup only runs if etcd is actually
available, preventing teardown failures from failing the entire suite when
infrastructure is unavailable.
🪄 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: CHILL

Plan: Pro

Run ID: 47eb2a68-4b09-4fc1-8b62-5de06116e6e4

📥 Commits

Reviewing files that changed from the base of the PR and between ca2542e and 5102235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-ratelimit/Cargo.toml
  • crates/aisix-ratelimit/src/lib.rs
  • crates/aisix-ratelimit/src/limiter.rs
  • crates/aisix-ratelimit/src/store/local.rs
  • crates/aisix-ratelimit/src/store/mod.rs
  • crates/aisix-ratelimit/src/store/redis.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • docs/configuration/rate-limits.md
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts

Comment threadcrates/aisix-core/src/config.rs Outdated
Comment threadcrates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment threadcrates/aisix-server/src/main.rs
Comment threadtests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
… robustness
- main.rs: select the rate-limit store on `ratelimit.backend`, not on
`ratelimit.redis` presence, so a stray redis block under
`backend: memory` no longer silently activates Redis.
- config: reject `concurrency_ttl_secs: 0` for the redis backend (a zero
TTL prunes a slot in the same second it is taken, disabling concurrency
limiting). + unit test.
- redis integration test: poll (bounded) for the detached ZREM instead of
a fixed 200ms sleep.
- cluster e2e: guard the afterAll deletePrefix behind the readiness flag
so teardown doesn't fail when infra is unavailable.
@jarvis9443
jarvis9443 merged commit dbdcf20 into mainJun 15, 2026
10 checks passed
@jarvis9443
jarvis9443 deleted the feat/cluster-ratelimit-798 branch June 15, 2026 04:11
moonming added a commit that referenced this pull request Jun 15, 2026
)
Unbreaks main: #606 merged onto a main with #607's MultiReservation API change (semantic conflict, no textual conflict). Ports the streaming-ensemble reservation code to the new API and fixes the latent un-awaited commit_tokens (panel tokens were never billed on streaming error exits). Verified green locally + CI.
Sign up for freeto 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.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(ratelimit): cluster-level rate limiting via shared Redis - #607

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798
Jun 15, 2026
Merged

feat(ratelimit): cluster-level rate limiting via shared Redis#607
jarvis9443 merged 2 commits into
mainfrom
feat/cluster-ratelimit-798

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rate-limit counters live in per-process memory (FixedWindowCounter in a DashMap), so each DP replica counts only the traffic it personally served. A cluster of N replicas behind a load balancer therefore enforces N× every configured limit — a key capped at rpm: 1 gets one request per replica per minute. The reporter saw exactly this: instance :3000 returns 429 while :3001 still serves the same key.

Approach

Introduce a pluggable RateStore backend behind Limiter:

  • LocalStore — the historical per-process fixed-window counters, unchanged. Stays the default, so single-node and dev deployments behave exactly as before (all prior limiter unit tests pass against it verbatim).
  • RedisStore — shares the counters across every replica through one Redis, so the whole cluster enforces one global window. Counter math mirrors LocalStore/FixedWindowCounter (wall-clock-aligned windows now - now % window) so swapping memory ↔ redis doesn't change observable limits, only whether the count is shared.

RedisStore details:

  • One Lua per bucket does the atomic, all-or-nothing acquire: concurrency gate + token check-only + request check-and-increment. redis.call('TIME') is used for now so window boundaries are identical across replicas regardless of host clock skew.
  • Keys are namespaced aisix:rl: and hash-tagged {<bucket>} so all of a bucket's keys co-locate on one Redis Cluster slot (the per-bucket Lua stays atomic). The Redis may be the same instance used for the response cache.
  • All dimensions are shared, including concurrency: it's tracked as a ZSET semaphore (member → score=now) where acquire prunes entries older than concurrency_ttl_secs before counting, so a slot held by a crashed/hung replica is reclaimed within the TTL. (A window-TTL counter would mishandle long streaming responses — the same reason StreamConcurrencyGuard exists.)
  • On any Redis error the store fails open to per-replica in-memory counting (logged once): traffic keeps flowing during an outage and global enforcement resumes when Redis recovers.

The enforcement path (pre_commit/commit_tokens/peek) is now async; concurrency release stays a synchronous Drop (the Redis backend detaches a ZREM, bounded by the TTL prune). All LLM endpoints share the quota::enforce / enforce_rate_limit helpers, so every endpoint inherits the fix uniformly.

Configuration

New ratelimit block, defaulting to memory (current behaviour):

ratelimit:
backend: "redis"# memory | redisredis:
url: "redis://host:6379"concurrency_ttl_secs: 300

Reachable by env on managed/containerized deployments: AISIX_RATELIMIT__BACKEND=redis, AISIX_RATELIMIT__REDIS__URL=.... backend: redis without a redis block is rejected at boot.

Behaviour changes

  • Default deployments are unchanged (backend: memory).
  • Multi-replica deployments that opt into backend: redis now enforce limits cluster-wide instead of per replica.

Tests

  • Rust integration (crates/aisix-ratelimit/tests/redis_integration.rs, gated on RATELIMIT_TEST_REDIS_URL, CI provisions redis:7-alpine): two RedisStore instances share rpm/rps/tpm/concurrency counters; rps window rollover; stale concurrency slot reclaimed after TTL.
  • DP e2e (tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts): spins up two real aisix binaries on one shared etcd + one shared Redis with an rpm: 1 key — request to replica A → 200, request to replica B → 429 + Retry-After (the exact issue repro). A contrast suite with the default memory backend shows both replicas serve the request (the per-replica bug).
  • All existing limiter unit tests pass against LocalStore; new config-validation unit tests cover the ratelimit.redis rules.

Fixes api7/AISIX-Cloud#798

Summary by CodeRabbit

  • New Features

    • Added a ratelimit configuration block with selectable backend (memory default or redis) for cluster-wide rate limiting across multiple replicas.
    • Redis backend supports shared concurrency enforcement via concurrency_ttl_secs, enabling cross-replica in-flight slot reclamation.
    • When Redis is unavailable, rate limiting degrades gracefully to per-replica in-memory behavior while logging the incident.
  • Documentation

    • Expanded rate-limit documentation with “single node vs cluster” storage guidance and multi-replica behavior examples.
  • Tests

    • Added Redis-backed and multi-replica E2E coverage for rate-limit correctness and retry behavior.

Fixes api7/AISIX-Cloud#788

Rate-limit counters lived in per-process memory, so an N-replica DP
cluster enforced N× every configured limit (a key capped at rpm:1 got
one request per replica per minute). Add a Redis-backed shared store so
the whole cluster enforces one global window.
- Introduce a `RateStore` backend behind `Limiter`: `LocalStore`
(unchanged in-memory default) and `RedisStore` (Lua check-and-increment
over wall-clock-aligned fixed windows, `redis.call('TIME')` for
cross-replica window consistency, hash-tagged keys for Cluster slot
co-location). All dimensions are shared — rps/rpm/rph/rpd/tpm/tpd plus
concurrency, tracked as a crash-safe ZSET semaphore reclaimed after
`concurrency_ttl_secs`. On a Redis outage the store fails open to
per-replica counting.
- Make the enforcement path async (`pre_commit`/`commit_tokens`/`peek`);
concurrency release stays a sync `Drop` (Redis detaches a ZREM).
- New `ratelimit` config block (`backend: memory|redis`, `redis`,
`concurrency_ttl_secs`), enabled via env on managed deployments.
Tests: Rust integration (gated on RATELIMIT_TEST_REDIS_URL) for shared
rpm/rps/tpm/concurrency + TTL reclaim; DP e2e spins two real binaries on
one Redis (A→200, B→429) plus a memory-backend regression (both 200).
Fixesapi7/AISIX-Cloud#798
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5736522-d2aa-4a9c-9eec-8edab704f19a

📥 Commits

Reviewing files that changed from the base of the PR and between 5102235 and 3dc567c.

📒 Files selected for processing (4)
  • crates/aisix-core/src/config.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/aisix-server/src/main.rs
  • crates/aisix-core/src/config.rs
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
  • crates/aisix-ratelimit/tests/redis_integration.rs

📝 Walkthrough

Walkthrough

Adds a pluggable RateStore trait with LocalStore (in-process fixed-window) and RedisStore (Lua-script atomic, fail-open) backends. Refactors Limiter, Reservation, MultiReservation, and StreamConcurrencyGuard from sync clock-generic to async store-backed. Migrates proxy quota, chat, and embeddings paths to async reservation APIs. Wires conditional Redis initialization at server startup via a new ratelimit config block with validation and environment variable support.

Changes

Cluster-wide rate limiting

Layer / File(s)Summary
RateLimitConfig types, validation, and re-exports
crates/aisix-core/src/config.rs, crates/aisix-core/src/lib.rs, config.example.yaml, config.managed.yaml
Adds RateLimitConfig struct and RateLimitBackend enum to Config, a boot-time check requiring ratelimit.redis block and non-zero concurrency_ttl_secs when backend is redis, four config tests, public re-exports of the new types, and documented config file examples.
RateStore trait and LocalStore backend
crates/aisix-ratelimit/src/store/mod.rs, crates/aisix-ratelimit/src/store/local.rs
Defines the RateStore trait (async acquire/commit/peek, sync release/add_tokens), shared window-dimension constants and helpers (request_dims, token_dims), and the in-process LocalStore with per-key DashMap state, layered request-window rollback-on-reject acquire logic, and peek.
RedisStore Lua scripts and RateStore implementation
crates/aisix-ratelimit/Cargo.toml, crates/aisix-ratelimit/src/store/redis.rs
Adds async-trait, redis, uuid dependencies; embeds four Lua scripts (ACQUIRE, COMMIT, ADD_TOKENS, PEEK) for atomic Redis operations with ZSET concurrency semaphore; implements RedisStore with fail-open fallback to LocalStore, fire-and-forget release/add_tokens via spawned tasks, and connect/with_conc_ttl constructors.
Limiter refactored to async store-backed API
crates/aisix-ratelimit/src/lib.rs, crates/aisix-ratelimit/src/limiter.rs
Replaces clock-generic in-memory Limiter<C> with a store-backed Limiter backed by Arc<dyn RateStore>; makes pre_commit and peek async; reworks Reservation, MultiReservation, and StreamConcurrencyGuard to own store references and release concurrency on drop; converts all unit tests to #[tokio::test].
Proxy quota, chat, and embeddings async migration
crates/aisix-proxy/src/quota.rs, crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/embeddings.rs
Converts reserve_layers, enforce, and enforce_rate_limit to async fn and awaits all pre_commit calls; updates all commit_tokens, peek, into_stream_hold, and dispatch_ensemble call sites in chat and embeddings to use the async API without passing limiter to hold().
Server startup conditional Redis initialization
crates/aisix-server/src/main.rs, .github/workflows/ci.yml
Replaces unconditional Limiter::new() with a branch that connects RedisStore when cfg.ratelimit.backend is Redis, applies concurrency_ttl_secs, and wraps it in Limiter::with_store; adds RATELIMIT_TEST_REDIS_URL to CI environment for integration tests.
Redis integration tests, E2E cluster tests, and docs
crates/aisix-ratelimit/tests/redis_integration.rs, tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts, docs/configuration/rate-limits.md
Adds RedisStore integration tests for shared RPM/RPS/token/concurrency/TTL across two store instances; adds E2E cluster tests asserting cross-replica 429 enforcement with Redis backend and independent per-replica 200s with in-memory backend; documents counter storage backends and operator guidance for multi-replica deployments.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ProxyHandler as chat.rs / embeddings.rs
participant Quota as quota.rs enforce_rate_limit
participant Limiter
participant RateStore as LocalStore or RedisStore
participant Redis
Client->>ProxyHandler: POST /v1/chat/completions
ProxyHandler->>Quota: enforce_rate_limit(state, auth, model_rl).await
Quota->>Limiter: pre_commit(key, limits).await
Limiter->>RateStore: acquire(key, limits, member).await
alt Redis backend
RateStore->>Redis: EVALSHA ACQUIRE_LUA
Redis-->>RateStore: ok / rate_limit_error
end
RateStore-->>Limiter: Ok(()) or RateLimitError
Limiter-->>Quota: Reservation
Quota-->>ProxyHandler: MultiReservation
alt streaming
ProxyHandler->>ProxyHandler: into_stream_hold() → StreamConcurrencyGuard
Note over ProxyHandler: concurrency held for stream lifetime
ProxyHandler->>RateStore: add_tokens_post_stream(key, tokens)
Note over ProxyHandler: guard dropped → release(key, member)
else non-streaming / cache-hit
ProxyHandler->>ProxyHandler: reservation.commit_tokens(tokens).await
ProxyHandler->>RateStore: commit(key, tokens, member).await
alt Redis backend
RateStore->>Redis: EVALSHA COMMIT_LUA
end
end
ProxyHandler-->>Client: 200 OK or 429 Too Many Requests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • api7/ai-gateway#481: Both PRs modify MultiReservation::into_stream_hold and StreamConcurrencyGuard to hold concurrency permits for the lifetime of streaming responses, with this PR restructuring the guard ownership model to support the async store backend.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe pull request title accurately reflects the main change: introducing cluster-level rate limiting via shared Redis backend. It is concise, specific, and clearly describes the primary objective.
Linked Issues check✅ PassedAll requirements from issue #798 are met: the PR implements cluster-level rate limiting via shared Redis, with LocalStore as default for backward compatibility, atomic enforcement via Lua scripts, ZSET-based concurrency tracking, fail-open behavior on Redis errors, configuration via ratelimit.backend, and comprehensive testing including e2e multi-replica verification.
Out of Scope Changes check✅ PassedAll changes are directly scoped to implementing cluster-level rate limiting: RateStore abstraction, LocalStore and RedisStore implementations, async refactoring of enforcement paths, configuration additions, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@crates/aisix-core/src/config.rs`:
- Around line 704-708: Add a validation check in the same config validation
block that checks the Redis backend to also enforce that concurrency_ttl_secs
must be a positive value (greater than 0) when the Redis backend is selected.
When Redis semaphore reclamation is enabled, a concurrency_ttl_secs value of 0
would immediately reclaim active slots and disable concurrency limiting, so add
a condition to return a BootstrapError::Config with a clear message if
concurrency_ttl_secs is 0 or negative while using the Redis backend, similar to
the existing Redis backend validation pattern.
In `@crates/aisix-ratelimit/tests/redis_integration.rs`:
- Around line 161-167: Replace the hardcoded 200ms sleep after a.release(&key,
"a-1") with bounded polling that repeatedly attempts the b.acquire(&key,
&limits, "b-2") operation until it succeeds or a reasonable timeout is reached.
Instead of assuming a fixed propagation delay, use a loop with
tokio::time::timeout or a similar mechanism to poll for the actual condition
(slot availability) rather than sleeping, which makes the test robust to varying
CI executor speeds.
In `@crates/aisix-server/src/main.rs`:
- Around line 379-401: The rate limiter selection logic does not respect the
`ratelimit.backend` configuration setting. Currently, the match expression at
line 385 only checks if `cfg.ratelimit.redis` is present, which means the redis
backend can be used even when `backend: memory` is configured. Modify the
branching logic to check both the `cfg.ratelimit.backend` value AND the presence
of `cfg.ratelimit.redis`. Only instantiate the redis-backed limiter when
`backend` is explicitly set to redis and the `cfg.ratelimit.redis` block is
present; otherwise, use the memory backend with `Limiter::new()`. This ensures
the user's backend configuration choice is honored regardless of whether a redis
block exists in the config.
In `@tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts`:
- Around line 140-147: The afterAll hook in the ratelimit-cluster-e2e.test.ts
file calls deletePrefix without checking whether the etcd infrastructure is
available, which causes test suite failures when etcd is unavailable even though
tests correctly skip via ctx.skip() when infra is down. Guard the deletePrefix
call (at line 146 in the afterAll hook and also at line 195 in another afterAll
hook) behind a readiness check to ensure cleanup only runs if etcd is actually
available, preventing teardown failures from failing the entire suite when
infrastructure is unavailable.
🪄 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: CHILL

Plan: Pro

Run ID: 47eb2a68-4b09-4fc1-8b62-5de06116e6e4

📥 Commits

Reviewing files that changed from the base of the PR and between ca2542e and 5102235.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • config.example.yaml
  • config.managed.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/quota.rs
  • crates/aisix-ratelimit/Cargo.toml
  • crates/aisix-ratelimit/src/lib.rs
  • crates/aisix-ratelimit/src/limiter.rs
  • crates/aisix-ratelimit/src/store/local.rs
  • crates/aisix-ratelimit/src/store/mod.rs
  • crates/aisix-ratelimit/src/store/redis.rs
  • crates/aisix-ratelimit/tests/redis_integration.rs
  • crates/aisix-server/src/main.rs
  • docs/configuration/rate-limits.md
  • tests/e2e/src/cases/ratelimit-cluster-e2e.test.ts

Comment threadcrates/aisix-core/src/config.rs Outdated
Comment threadcrates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment threadcrates/aisix-server/src/main.rs
Comment threadtests/e2e/src/cases/ratelimit-cluster-e2e.test.ts
… robustness
- main.rs: select the rate-limit store on `ratelimit.backend`, not on
`ratelimit.redis` presence, so a stray redis block under
`backend: memory` no longer silently activates Redis.
- config: reject `concurrency_ttl_secs: 0` for the redis backend (a zero
TTL prunes a slot in the same second it is taken, disabling concurrency
limiting). + unit test.
- redis integration test: poll (bounded) for the detached ZREM instead of
a fixed 200ms sleep.
- cluster e2e: guard the afterAll deletePrefix behind the readiness flag
so teardown doesn't fail when infra is unavailable.
@jarvis9443
jarvis9443 merged commit dbdcf20 into mainJun 15, 2026
10 checks passed
@jarvis9443
jarvis9443 deleted the feat/cluster-ratelimit-798 branch June 15, 2026 04:11
moonming added a commit that referenced this pull request Jun 15, 2026
)
Unbreaks main: #606 merged onto a main with #607's MultiReservation API change (semantic conflict, no textual conflict). Ports the streaming-ensemble reservation code to the new API and fixes the latent un-awaited commit_tokens (panel tokens were never billed on streaming error exits). Verified green locally + CI.
Sign up for freeto 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.

1 participant

@jarvis9443