A distributed rate limiting service backed by Redis, supporting multiple algorithms with atomic Lua script execution. Built with Java 17 and Spring Boot 3.
┌─────────────┐ ┌───────────────────────┐ ┌──────────────────────┐
│ Client │────▶│ RateLimitInterceptor │────▶│ RateLimiterService │
│ (HTTP) │ │ (extracts client ID) │ │ (orchestrates) │
└─────────────┘ └───────────────────────┘ └──────────┬───────────┘
▲ │ │
│ │ ┌─────────▼─────────┐
│ 429 or 200 │ RateLimitConfig │
│ + headers │ Service │
│ └─────────┬─────────┘
│ │
│ ┌─────────▼─────────┐
│ │ Algorithm Strategy │
│ │ (Token Bucket / │
│ │ Sliding Window / │
│ │ Fixed Window) │
│ └─────────┬─────────┘
│ │
│ Lua Script
│ (atomic)
│ │
│ ┌─────────▼─────────┐
└───────────────────────────────────────────│ Redis │
└───────────────────┘
Best for bursty traffic. Tokens refill at a steady rate. Each request consumes one token. Allows short bursts up to bucket capacity while maintaining an average rate.
- Redis keys: Hash with
tokensandlast_refillfields - Refill rate:
maxRequests / windowSecondstokens per second - Pros: Smooth rate limiting, handles bursts gracefully
- Cons: Slightly more complex state management
Most precise algorithm. Maintains a log of all request timestamps in the current window using a Redis Sorted Set. Provides exact counting with no boundary issues.
- Redis keys: Sorted Set (ZSET) with timestamps as scores
- Pros: No boundary edge cases, exact counts
- Cons: Higher memory usage (stores each request timestamp)
Simplest algorithm. Divides time into fixed windows and counts requests per window using a simple counter.
- Redis keys: String counter with TTL (e.g.,
key:1711584000) - Pros: Minimal memory, simple implementation
- Cons: Boundary problem -- up to 2x burst at window edges
All rate limiting operations use Lua scripts executed atomically on the Redis server. Without atomicity, a race condition exists between "check current count" and "increment count" -- two concurrent requests could both read count=9 (limit=10), both increment, and allow 11 requests through.
Lua scripts execute as a single atomic operation on Redis, eliminating this race condition entirely. This is verified by the concurrency test which fires 20 simultaneous threads and asserts exactly 10 are allowed.
- Java 17 + Spring Boot 3.2
- Redis 7 (distributed state store)
- Redis Lua scripting (atomic rate limit operations)
- Docker Compose (local infrastructure)
- JUnit 5 + Mockito (unit tests)
- Testcontainers (integration tests with real Redis)
- Java 17+
- Docker and Docker Compose
- Maven (or use the included wrapper)
# 1. Start Redis
docker-compose up -d
# 2. Build and run
./mvnw spring-boot:run
# 3. Test rate limiting
# Token Bucket: 10 requests/minute on GET /api/resource
for i in {1..12}; do
echo -n "Request $i: "
curl -s -o /dev/null -w "%{http_code}" -H "X-Api-Key: demo-client" http://localhost:8080/api/resource
echo
done
# Requests 1-10: 200, Requests 11-12: 429
# Sliding Window: 3 requests/minute on POST /api/expensive
for i in {1..5}; do
echo -n "Request $i: "
curl -s -o /dev/null -w "%{http_code}" -X POST -H "X-Api-Key: demo-client" http://localhost:8080/api/expensive
echo
done
# Requests 1-3: 200, Requests 4-5: 429
# Fixed Window: 20 requests/minute on GET /api/data
# No rate limit on GET /api/unlimitedAll rate-limited responses include:
| Header | Description |
|---|---|
X-RateLimit-Remaining |
Number of requests remaining in the current window |
X-RateLimit-Retry-After |
Seconds to wait before retrying (only on 429) |
Clients are identified by the X-Api-Key header. If no API key is provided, the client's IP address is used as a fallback.
Rate limit rules are defined in application.yml:
rate-limit:
default-algorithm: TOKEN_BUCKET
rules:
- endpoint: /api/resource
method: GET
max-requests: 10
window-seconds: 60
algorithm: TOKEN_BUCKET# Unit tests only
./mvnw test -Dtest="*Test" -DexcludedGroups=integration
# All tests (requires Docker for Testcontainers)
./mvnw testsrc/main/java/com/samviksha/ratelimiter/
├── algorithm/ # Strategy pattern: RateLimitAlgorithm interface + 3 implementations
├── api/ # REST controllers (demo endpoints)
├── config/ # Redis config, rate limit properties binding
├── interceptor/ # Spring HandlerInterceptor for rate limiting
├── model/ # Data models (RateLimitRule, RateLimitResult, AlgorithmType)
└── service/ # Business logic (config resolution, algorithm orchestration)
src/main/resources/
├── scripts/ # Redis Lua scripts (token_bucket, sliding_window, fixed_window)
└── application.yml # Rate limit rule configuration
-
Strategy Pattern for algorithms -- Adding a new algorithm requires implementing one interface and one Lua script. No existing code changes needed (Open/Closed Principle).
-
Lua scripts over Redis transactions --
MULTI/EXECdoesn't support conditional logic (read-then-write). Lua scripts execute atomically AND support branching. -
Interceptor over annotation-based -- Interceptors apply transparently to all
/api/**routes based on config. No code changes needed to add rate limiting to a new endpoint. -
Config-driven rules -- Rate limits are defined in YAML, not hardcoded. Per-endpoint, per-method granularity with algorithm selection.
-
Client isolation via key prefix -- Each client gets independent rate limit counters (
rate:{clientId}:{method}:{endpoint}), preventing one client from affecting another.