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

@jarvis9443 jarvis9443 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 | redis
  redis:
    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).

Fixes api7/AISIX-Cloud#798
@coderabbitai

coderabbitai Bot 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 name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The 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 ✅ Passed All 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 ✅ Passed All 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.

@coderabbitai coderabbitai Bot 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 thread crates/aisix-core/src/config.rs Outdated
Comment thread crates/aisix-ratelimit/tests/redis_integration.rs Outdated
Comment thread crates/aisix-server/src/main.rs
Comment thread tests/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 main Jun 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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant