A distributed, low-latency rate limiting service built with Java 21, Spring Boot, Redis, and Lua.
The service provides two rate-limiting algorithms:
- Fixed Window Counter
- Sliding Window
Rate-limit state is stored in Redis and the critical counter/window operations are executed atomically using Redis Lua scripts, making the service suitable for distributed deployments where multiple application instances share the same rate-limit state.
Rate limiting is a fundamental mechanism for protecting APIs and backend services from excessive traffic.
Without rate limiting, a single client can potentially generate enough requests to:
- Exhaust server resources
- Increase database load
- Degrade service performance
- Abuse expensive APIs
- Cause cascading failures
- Affect other users of the system
This project implements a standalone rate-limiting service that can be placed in front of backend APIs or used as a shared infrastructure component.
The primary goal is to explore how rate limiting works at the system-design and implementation level, particularly in a distributed environment where multiple application instances need to make consistent rate-limit decisions.
The service exposes HTTP APIs where a caller provides:
- An identity
- A request limit
- A time window
For example:
{
"identity": "user:1234",
"limit": 5,
"windowSeconds": 20
}This configuration means:
Allow up to 5 requests for
user:1234within a 20-second window.
Once the configured quota has been exhausted, subsequent requests receive:
HTTP/1.1 429 Too Many RequestsThe response also provides information about when the client can retry.
The application runs on port 8081 and uses the context path:
/api/v1/rate-limiter
The default configuration is defined in application.yaml.
- Fixed Window Counter
- Sliding Window
- Per-identity rate limits
- Configurable request limits
- Configurable time windows
- Redis-backed state
- Shared rate-limit state across application instances
- Suitable for horizontally scaled deployments
Rate-limit decisions are executed inside Redis Lua scripts.
This keeps operations such as:
Read → Check → Increment → Expire
inside a single Redis-side operation.
200 OKwhen a request is allowed429 Too Many Requestswhen the limit is exceededRetry-Afterresponse header for rejected requests
The API validates:
- Identity must not be blank
- Limit must be greater than zero
- Window duration must be greater than zero
The request contract is implemented using Jakarta Bean Validation.
The repository contains Python-based integration tests covering:
- Normal requests
- Quota exhaustion
429responses- Retry timing
- Window expiration
- Concurrent requests
- Race conditions
- Independent identities
- Sliding-window behavior
- Boundary conditions
┌──────────────────────┐
│ Client │
│ API / Service / App │
└──────────┬───────────┘
│
│ HTTP
▼
┌──────────────────────────┐
│ Rate Limiter API │
│ │
│ Spring Boot / Java 21 │
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ RateLimitService │
└────────────┬─────────────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Fixed Window │ │ Sliding Window │
│ Strategy │ │ Strategy │
└────────┬─────────┘ └────────┬─────────┘
│ │
└───────────┬────────────┘
▼
┌─────────────────────────┐
│ RedisRateLimitRepository│
└────────────┬────────────┘
│
Redis Lua Scripts
│
▼
┌─────────────────────────┐
│ Redis │
│ │
│ Shared Rate-Limit State│
└─────────────────────────┘
The Java implementation separates the HTTP controllers, service layer, rate-limiting strategies, and Redis persistence layer.
The Fixed Window algorithm divides time into discrete windows.
For example:
Limit: 5 requests
Window: 20 seconds
The timeline looks like:
|--------- 20 seconds ---------|
0 20
Requests:
R1 R2 R3 R4 R5
└── allowed
R6
└── rejected
When the next window begins, the quota resets.
The Lua script calculates the current window using:
window = floor(currentTime / windowSeconds)
The Redis key is then constructed as:
rate-limit:fixed:<identity>:<window>
Each request increments the counter for the current window.
If:
currentCount <= limit
the request is allowed.
Otherwise it is rejected.
- Simple
- Low memory usage
- Fast
- Easy to reason about
- Efficient Redis representation
Fixed windows can produce a boundary burst.
For example, a client may consume its entire quota at the end of one window and immediately consume another full quota at the beginning of the next window.
Window 1 Window 2
|---------------------|---------------------|
5 requests | 5 requests
allowed | allowed
This can result in a burst of 10 requests over a period much shorter than the configured window.
The Sliding Window algorithm evaluates requests over the continuously moving interval:
[now - windowSeconds, now]
Instead of resetting the entire quota at a fixed boundary, individual requests expire as they move outside the window.
For example:
Limit = 5
Window = 20 seconds
If five requests arrive:
t=0
t=2
t=4
t=6
t=8
the quota becomes exhausted.
At:
t=20
the first request expires.
One slot becomes available.
At:
t=22
the second request expires.
Another slot becomes available.
This produces gradual quota recovery.
The Sliding Window implementation uses a Redis Sorted Set.
Each request is stored with a timestamp as its score.
The algorithm:
- Gets the current Redis server time.
- Calculates the start of the sliding window.
- Removes requests older than the window.
- Counts the remaining requests.
- Rejects the request if the count has reached the limit.
- Otherwise inserts the request into the sorted set.
- Refreshes the Redis key expiration.
The Redis key follows:
rate-limit:sliding:<identity>
The Lua script uses microsecond-resolution timestamps so requests occurring within the same second remain correctly ordered.
- More accurate than fixed windows
- Prevents fixed-window boundary bursts
- Quota becomes available gradually
- Better representation of real request traffic
The Sliding Window requires storing individual request entries in a Redis Sorted Set, so it consumes more memory than the Fixed Window counter.
| Technology | Purpose |
|---|---|
| Java 21 | Application runtime |
| Spring Boot | Application framework |
| Spring MVC | REST API |
| Spring Data Redis | Redis integration |
| Redis | Distributed state store |
| Redis Lua | Atomic rate-limit operations |
| Maven | Build and dependency management |
| Docker | Containerization |
| Docker Compose | Local multi-container environment |
| Python | Integration testing |
The Maven configuration currently targets Java 21 and includes Spring Boot, Redis, validation, MVC, and Actuator dependencies.
Rate-Limiter/
│
├── .mvn/
│ └── wrapper/
│
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── dev/
│ │ │ └── suwe/
│ │ │ ├── controller/
│ │ │ │ ├── FixedWindowController.java
│ │ │ │ └── SlidingWindowController.java
│ │ │ │
│ │ │ ├── database/
│ │ │ │ └── redis/
│ │ │ │ ├── RedisConfig.java
│ │ │ │ ├── RedisLuaResult.java
│ │ │ │ └── RedisRateLimitRepository.java
│ │ │ │
│ │ │ ├── dto/
│ │ │ │ ├── RateLimitRequest.java
│ │ │ │ └── RateLimitResponse.java
│ │ │ │
│ │ │ ├── exception/
│ │ │ │
│ │ │ ├── service/
│ │ │ │ └── RateLimitService.java
│ │ │ │
│ │ │ ├── strategy/
│ │ │ │ ├── RateLimitStrategy.java
│ │ │ │ ├── FixedWindowStrategy.java
│ │ │ │ └── SlidingWindowStrategy.java
│ │ │ │
│ │ │ └── RateLimiterApplication.java
│ │ │
│ │ └── resources/
│ │ ├── scripts/
│ │ │ ├── fixed-window.lua
│ │ │ └── sliding-window.lua
│ │ │
│ │ └── application.yaml
│ │
│ └── test/
│ └── java/
│
├── test_fixed_window.py
├── test_sliding_window.py
│
├── Dockerfile
├── docker-compose.yml
├── pom.xml
├── mvnw
└── mvnw.cmd
Java 21 or later.
Verify:
java -versionMaven is not strictly required because the repository includes the Maven Wrapper.
You can use:
./mvnwinstead of installing Maven globally.
Redis is required for the rate limiter.
Verify:
redis-cli pingExpected:
PONG
For the easiest setup:
- Docker
- Docker Compose
The repository already contains both a Dockerfile and docker-compose.yml.
Python is required only if you want to run the integration test suites.
The tests use the requests package.
Install it with:
pip install requestsgit clone https://github.com/suwe-dev/Rate-Limiter.git
cd Rate-LimiterThe easiest way to start the complete application is:
docker compose up --build -dThis starts:
rate-limiter-app
│
▼
redis-server
The application is exposed on:
http://localhost:8081
Redis runs internally on the Docker network and does not need to be exposed to the host.
The Compose configuration connects the application to Redis using:
SPRING_DATA_REDIS_HOST=redis-server
SPRING_DATA_REDIS_PORT=6379
and exposes the application on port 8081.
Check running containers:
docker compose psView application logs:
docker compose logs -f appStop the application:
docker compose downStart Redis:
redis-serverThen start the Spring Boot application:
./mvnw spring-boot:runThe application will start on:
http://localhost:8081
with the base context:
/api/v1/rate-limiter
The default Redis configuration expects:
localhost:6379
as configured in application.yaml.
http://localhost:8081/api/v1/rate-limiter
Both algorithms accept the same request body:
{
"identity": "user:1234",
"limit": 5,
"windowSeconds": 20
}| Field | Type | Description |
|---|---|---|
identity |
String | Unique identifier being rate limited |
limit |
Integer | Maximum number of allowed requests |
windowSeconds |
Long | Duration of the rate-limit window |
The identity can represent almost anything meaningful to the application:
user:1234
api-key:abc123
ip:192.168.1.10
tenant:acme
service:payment
POST /api/v1/rate-limiter/fixed-windowcurl -X POST \
http://localhost:8081/api/v1/rate-limiter/fixed-window \
-H "Content-Type: application/json" \
-d '{
"identity": "user:1234",
"limit": 5,
"windowSeconds": 20
}'POST /api/v1/rate-limiter/sliding-windowcurl -X POST \
http://localhost:8081/api/v1/rate-limiter/sliding-window \
-H "Content-Type: application/json" \
-d '{
"identity": "user:1234",
"limit": 5,
"windowSeconds": 20
}'When a request is accepted:
HTTP/1.1 200 OKExample:
{
"allow": true,
"reason": null,
"retryAfterSeconds": null
}For the Sliding Window implementation, the underlying Lua script returns the number of remaining slots on an allowed request. The current Java response model exposes this value through the retryAfterSeconds field, so consumers should treat the field according to the algorithm's current implementation rather than assuming it always means "retry after".
Once the limit is reached:
HTTP/1.1 429 Too Many Requests
Retry-After: 17Example response:
{
"allow": false,
"reason": "Rate limit exceeded",
"retryAfterSeconds": 17
}The controllers explicitly return HTTP 429 and populate the standard Retry-After header when the request is rejected.
Invalid requests are rejected through Jakarta Bean Validation.
{
"identity": "",
"limit": 5,
"windowSeconds": 20
}Invalid because identity must not be blank.
{
"identity": "user:1234",
"limit": 0,
"windowSeconds": 20
}Invalid because the limit must be greater than zero.
{
"identity": "user:1234",
"limit": 5,
"windowSeconds": 0
}Invalid because windowSeconds must be greater than zero.
The project includes dedicated Python integration test suites.
pip install requestsMake sure the application and Redis are already running.
Run:
python test_fixed_window.pyThe test suite validates:
- Requests within quota return
200 - Requests beyond quota return
429 Retry-Afteris returnedretryAfterSecondsbehaves correctly- Window expiration restores the quota
- Concurrent requests do not exceed the configured limit
The fixed-window suite targets:
http://localhost:8081/api/v1/rate-limiter/fixed-window
and uses a default test configuration of:
10 requests / 30 seconds
Run:
python test_sliding_window.pyThe Sliding Window suite performs more extensive behavioral testing, including:
- Requests within quota
- Quota exhaustion
- HTTP
429 - Retry timing
- Gradual quota recovery
- Fixed-window boundary burst prevention
- Window-edge behavior
- Concurrent requests
- Race-condition checks
- Independent identities
- Tiny-window behavior
- Invalid limits
A typical request flows through the service as follows:
1. Client
│
▼
2. REST Controller
│
▼
3. RateLimitService
│
▼
4. RateLimitStrategy
│
▼
5. RedisRateLimitRepository
│
▼
6. Redis Lua Script
│
▼
7. Atomic rate-limit decision
│
▼
8. HTTP 200 / 429
The service layer delegates the request to the appropriate strategy, while the strategies delegate Redis operations to the repository.
This separation makes the algorithm implementation independent from the HTTP layer.
A local in-memory rate limiter works only when all requests for an identity reach the same application instance.
Consider:
Load Balancer
│
┌──────────┼──────────┐
▼ ▼ ▼
Server A Server B Server C
│ │ │
└──────────┼──────────┘
▼
Redis
If each server maintains its own counter:
Server A → 5 requests
Server B → 5 requests
Server C → 5 requests
a configured limit of 5 could accidentally become:
15 requests
because each instance has independent state.
This project moves the state into Redis:
Server A ──┐
Server B ──┼──► Redis
Server C ──┘
All application instances therefore evaluate the same identity against the same shared state.
This is the key property that makes the implementation suitable for distributed deployments.
Rate limiting is inherently sensitive to race conditions.
Consider two requests arriving simultaneously:
Request A ──┐
├── Check count → Increment
Request B ──┘
If the operations are performed independently, both requests may observe the same old count before either increment occurs.
This can result in:
Configured limit: 10
Actual successful requests: 11
or more under heavy concurrency.
This project addresses the critical operation using Redis Lua scripts.
The rate-limit decision is performed inside Redis rather than distributing the individual operations across multiple network calls.
For Fixed Window, the Lua script performs the counter increment and decision together.
For Sliding Window, cleanup, counting, decision, and insertion are executed by the Lua script.
This is also why the integration tests explicitly include concurrent burst scenarios and race-condition checks.
Keys follow:
rate-limit:fixed:<identity>:<window>
Example:
rate-limit:fixed:user:1234:88234567
The value is a request counter.
The key receives a TTL corresponding to the remaining time in the current window.
Keys follow:
rate-limit:sliding:<identity>
The value is a Redis Sorted Set.
Conceptually:
Key:
rate-limit:sliding:user:1234
Sorted Set:
timestamp request
--------- ----------------
1725000000.10 request-a
1725000000.52 request-b
1725000001.14 request-c
1725000002.91 request-d
The timestamp is used as the score.
When a request arrives, entries older than the current sliding window are removed before calculating the current request count.
Default application configuration:
server:
port: 8081
servlet:
context-path: /api/v1/rate-limiter
spring:
application:
name: rate-limiter
data:
redis:
host: localhost
port: 6379
timeout: 2sWhen running through Docker Compose, the Redis host is changed to:
redis-server
because the application communicates with Redis through the Docker network.
The Docker image uses a multi-stage build.
Maven + Eclipse Temurin 21
The application is compiled and packaged into a JAR.
Eclipse Temurin 21 JRE Alpine
The final container runs using a non-root user:
appuser
and exposes:
8081
This keeps the runtime image smaller and avoids running the application as root.
The Compose configuration defines two services:
┌─────────────────────────┐
│ rate-limiter-app │
│ │
│ Spring Boot │
│ Port 8081 │
└────────────┬────────────┘
│
│ Docker Network
▼
┌─────────────────────────┐
│ redis-server │
│ │
│ Redis 8.2 Alpine │
│ Port 6379 │
└─────────────────────────┘
Redis is connected to the application through the internal Docker network.
Redis is a natural fit for rate limiting because it provides:
- Low-latency reads and writes
- Atomic operations
- TTL support
- Sorted Sets
- Lua scripting
- Shared state between application instances
Without Lua, a rate-limit operation could require multiple Redis commands:
GET
CHECK
INCR
EXPIRE
Multiple commands introduce opportunities for race conditions between concurrent clients.
Lua allows the complete decision-making operation to execute within Redis as one server-side script.
The application defines:
RateLimitStrategy
with concrete implementations:
FixedWindowStrategy
SlidingWindowStrategy
This keeps the algorithms isolated from the service and controller layers.
Adding another algorithm can therefore be done without rewriting the HTTP API layer.
Potential future strategies could include:
TokenBucketStrategy
LeakyBucketStrategy
| Property | Fixed Window | Sliding Window |
|---|---|---|
| Implementation | Counter | Sorted Set |
| Memory usage | Low | Higher |
| Complexity | Low | Higher |
| Boundary burst | Possible | Prevented |
| Quota recovery | All at once | Gradual |
| Redis structure | String/Counter | Sorted Set |
| Best for | Simple quotas | More accurate traffic control |
| Lua required | Yes | Yes |
POST /payments
Limit:
10 requests / 60 seconds
Prevents a client from repeatedly calling an expensive payment endpoint.
{
"identity": "user:1234",
"limit": 100,
"windowSeconds": 60
}Each user receives an independent quota.
{
"identity": "api-key:customer-abc",
"limit": 1000,
"windowSeconds": 3600
}Useful for public APIs with customer-specific quotas.
{
"identity": "tenant:acme",
"limit": 5000,
"windowSeconds": 60
}Useful in multi-tenant backend systems.
This project is primarily an implementation and learning project rather than a complete production gateway.
Current limitations include:
- No authentication or authorization
- No API-key management
- No persistent rate-limit policy configuration
- Rate-limit configuration is supplied by the caller
- No built-in distributed configuration management
- No metrics dashboard
- No circuit-breaker integration
- No automatic client identification
- No policy management UI
- No built-in service discovery
- No configurable fail-open/fail-closed behavior for Redis outages
For a production deployment, rate-limit policies would typically be controlled by the service rather than allowing arbitrary clients to supply their own limits.
Possible extensions include:
- Token Bucket
- Leaky Bucket
- Generic Cell Rate Algorithm
Instead of accepting the limit from every request:
{
"identity": "user:1234"
}the service could resolve policies internally:
user:1234
│
▼
Policy Service
│
├── limit = 100
└── window = 60s
Add standardized headers such as:
X-RateLimit-Limit
X-RateLimit-Remaining
X-RateLimit-Reset
Add:
- Prometheus metrics
- Grafana dashboards
- Request latency metrics
- Allowed/rejected request counters
- Redis operation metrics
- Algorithm-specific metrics
Introduce:
- Redis Sentinel
- Redis Cluster
- Multiple application replicas
- Load balancing
Move configuration into:
- Environment variables
- Spring profiles
- External configuration
- Centralized policy storage
This project demonstrates several backend and distributed-system concepts:
- Rate limiting algorithms
- Fixed Window vs Sliding Window
- Redis data structures
- Redis Sorted Sets
- Redis TTLs
- Redis Lua scripting
- Atomic operations
- Race-condition prevention
- Distributed shared state
- Spring Boot REST APIs
- Strategy Pattern
- Docker containerization
- Docker Compose
- Integration testing
- Concurrent request testing
- HTTP
429 Too Many Requests Retry-Aftersemantics
The shortest path to run everything:
docker compose up --build -ddocker compose pspython test_fixed_window.pypython test_sliding_window.pydocker compose downcurl -X POST \
http://localhost:8081/api/v1/rate-limiter/sliding-window \
-H "Content-Type: application/json" \
-d '{
"identity": "user:1234",
"limit": 5,
"windowSeconds": 20
}'Possible response:
{
"allow": true,
"reason": null,
"retryAfterSeconds": 4
}After the quota is exhausted:
{
"allow": false,
"reason": "Rate limit exceeded",
"retryAfterSeconds": 17
}with:
HTTP/1.1 429 Too Many Requests
Retry-After: 17This project implements a distributed rate-limiting service using Spring Boot, Redis, and Lua, with a focus on correctness under concurrent traffic.
The key architectural idea is simple:
Application instances
│
▼
Redis
│
▼
Atomic Lua operation
│
▼
Allow / Reject
The Fixed Window implementation provides a lightweight counter-based approach, while the Sliding Window implementation provides more precise control by tracking individual request timestamps.
Together, they provide a practical exploration of how rate limiting can be implemented as a shared backend service rather than as local application state.
This project is licensed under the MIT License.
You are free to use, copy, modify, distribute, and use this project for personal or commercial purposes, subject to the terms of the license.
Suwethan M
GitHub: https://github.com/suwe-dev