A reusable, annotation-driven, Redis-backed rate limiting library for Spring Boot applications, packaged as a proper Spring Boot starter.
custom-rate-limiter/
├── pom.xml (parent POM)
├── docker-compose.yml (Redis for local runs)
├── rate-limiter-spring-boot-starter/ (the reusable library)
└── demo-application/ (a sample app consuming it)
Add one dependency:
<dependency>
<groupId>com.soham</groupId>
<artifactId>rate-limiter-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>Annotate a controller method:
@RateLimit(limit = 100, windowSeconds = 60, algorithm = Algorithm.TOKEN_BUCKET)
@GetMapping("/books")
public List<Book> getBooks() {
return books;
}Callers identify themselves with an X-User-Id header. Requests within the
limit pass through untouched; requests over the limit get:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{"message": "Rate limit exceeded"}
Nothing else is required. No beans, no Redis client setup, no
@EnableXxx annotation on the consuming application — everything is wired
automatically by Spring Boot autoconfiguration.
┌─────────────────────────┐
HTTP request │ BookController │
───────────────▶│ @RateLimit(...) │
└───────────┬─────────────┘
│ (method call intercepted)
▼
┌─────────────────────────┐
│ RateLimitAspect │ Spring AOP @Before advice.
│ (reads annotation, │ Pure adapter: no business logic.
│ HTTP request) │
└───────────┬─────────────┘
│ isAllowed(userId, endpoint, limit, window, algorithm)
▼
┌─────────────────────────┐
│ RateLimiterService │ Orchestration only.
└───────────┬─────────────┘
│ getStrategy(algorithm)
▼
┌─────────────────────────┐
│ RateLimitStrategyFactory│ Map<Algorithm, RateLimitStrategy>
└───────────┬─────────────┘
│
┌───────────────┼────────────────┬─────────────────┐
▼ ▼ ▼ ▼
FixedWindow SlidingWindow TokenBucket LeakyBucket
Strategy Strategy Strategy Strategy
│ │ │ │
└───────────────┴────────┬───────┴─────────────────┘
▼
Redis
Each layer has one job (Single Responsibility Principle):
| Component | Responsibility |
|---|---|
@RateLimit |
Declarative configuration on a handler method |
RateLimitAspect |
Spring AOP interception + HTTP/reflection plumbing only |
RateLimiterService |
Business orchestration: pick a strategy, ask it a yes/no question |
RateLimitStrategyFactory |
Resolves Algorithm -> RateLimitStrategy via a Map, no if/else chains |
RateLimitStrategy implementations |
The actual algorithm, backed by Redis |
RateLimiterAutoConfiguration |
Wires every bean above together automatically |
RateLimitExceededException + RateLimitExceptionHandler |
Translates a rejected request into HTTP 429 |
RateLimitStrategyFactory holds a Map<Algorithm, RateLimitStrategy> built
once in RateLimiterAutoConfiguration. Nothing in the factory, the service,
or the aspect ever branches on which algorithm is active — they all just call
strategy.allowRequest(...). This satisfies the Open/Closed Principle: the
system is open for extension (new algorithms) but closed for modification
(no existing class changes when one is added).
RateLimitAspectis an AspectJ@Aspectbean, registered byRateLimiterAutoConfiguration. Spring's AOP auto-proxy machinery wraps any bean whose method carries@RateLimitin a proxy.- On each call, the
@Before("@annotation(rateLimit)")advice runs before the real controller method:- Reads the current
HttpServletRequestviaRequestContextHolderand pulls theX-User-Idheader (falls back to"anonymous"if absent). - Builds an endpoint identifier from the intercepted method
(
ClassName#methodName). - Delegates to
RateLimiterService.isAllowed(...).
- Reads the current
RateLimiterServiceasksRateLimitStrategyFactoryfor the strategy matching the annotation'salgorithm()value, and callsallowRequest(userId, endpoint, config)on it.- Each strategy reads/writes its own state shape in Redis (counters,
sorted sets, or hashes — see the Javadoc on each class) and returns
true/false. - If
false, the aspect throwsRateLimitExceededException.RateLimitExceptionHandler(a@RestControllerAdvicebean, also auto-registered) catches it and returns HTTP 429 with the documented JSON body — before the real controller method ever runs.
Because the state lives in Redis rather than JVM memory, the limit is enforced correctly even when the consuming application is scaled out to multiple instances behind a load balancer.
Every strategy uses RedisKeyGenerator so keys always look like:
rate_limit:<algorithm>:<userId>:<endpoint>
e.g. rate_limit:fixed_window:soham:BookController#getBooks. Including the
algorithm name means switching a method's algorithm attribute never
collides with counters an earlier configuration left behind.
| Algorithm | Redis structure | Notes |
|---|---|---|
FIXED_WINDOW |
STRING counter + TTL |
INCR, TTL set only on the first increment |
SLIDING_WINDOW |
ZSET of request timestamps |
Old entries trimmed with ZREMRANGEBYSCORE, counted with ZCARD |
TOKEN_BUCKET |
HASH (tokens, lastRefill) |
Atomic read-refill-decrement via a Lua script |
LEAKY_BUCKET |
HASH (level, lastLeak) |
Atomic read-leak-increment via a Lua script |
Token bucket and leaky bucket use Lua scripts (EVAL) so the
read-modify-write sequence is atomic even when multiple application
instances hit Redis concurrently — a plain Java "read, compute, write"
would race under concurrent load.
mvn installthis project (or publishrate-limiter-spring-boot-starterto your artifact repository).- Add the dependency shown in section 1 to your app's POM.
- Make sure
spring.data.redis.host/spring.data.redis.portpoint at a reachable Redis (Spring Boot's own Redis autoconfiguration handles the connection factory; this starter reuses whateverStringRedisTemplateSpring Boot creates from those properties). - Annotate any
@GetMapping/@PostMapping/etc. method with@RateLimit. - Send requests with an
X-User-Idheader.
That's it — see demo-application for a complete, minimal example.
No existing class needs to change. Three steps:
- Add an enum constant in
Algorithm:public enum Algorithm { FIXED_WINDOW, SLIDING_WINDOW, TOKEN_BUCKET, LEAKY_BUCKET, GCRA // <-- new }
- Implement the strategy:
public class GcraStrategy implements RateLimitStrategy { @Override public boolean allowRequest(String userId, String endpoint, RateLimitConfig config) { // your Redis-backed logic here } }
- Register it in
RateLimiterAutoConfiguration— add a@Beanmethod for it and one line in the map insiderateLimitStrategyFactory(...):strategies.put(Algorithm.GCRA, gcraStrategy);
Nothing in RateLimitAspect, RateLimiterService, or
RateLimitStrategyFactory changes. Consumers immediately get the new
algorithm by writing algorithm = Algorithm.GCRA on their @RateLimit
annotation.
docker compose up -dThis starts Redis 7 on localhost:6379 (matching demo-application's
application.yml). Stop it with:
docker compose downdocker compose up -d # start Redis
cd demo-application
mvn spring-boot:runThen, from another terminal:
# Allowed (well within the limit of 5/60s on FIXED_WINDOW)
curl -i http://localhost:8080/books -H "X-User-Id: soham"
# Hit it 6 times in a row to see HTTP 429 on the 6th
for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/books -H "X-User-Id: soham"; done
# A TOKEN_BUCKET endpoint (limit = 3 per 30s) to see burst behavior
curl -i http://localhost:8080/books/burstable -H "X-User-Id: soham"mvn clean testrate-limiter-spring-boot-startertests run each strategy against an embedded, in-memory Redis (jedis-mock) — no Docker required to run unit tests:FixedWindowStrategyTest,SlidingWindowStrategyTest,TokenBucketStrategyTest,LeakyBucketStrategyTest— per-algorithm allow/reject/isolation behavior.RateLimitStrategyFactoryTest— correct resolution and algorithm switching, plus the error path for an unregistered algorithm.RateLimitAspectTest— annotation interception,X-User-Idextraction (including the anonymous fallback), and that a rejected request throwsRateLimitExceededException.
demo-applicationhasRateLimitIntegrationTest— a full@SpringBootTest+MockMvctest proving the whole chain (HTTP request → AOP interception → Redis-backed strategy → HTTP 429) with zero rate-limiter beans declared in the demo app itself.
- Strategy Pattern —
RateLimitStrategy+ four interchangeable implementations. - Open/Closed Principle — new algorithms are additive; no existing class is modified.
- Single Responsibility Principle — annotation, aspect, service, strategy, and exception handling are all separate, independently testable classes.
- Dependency Injection — every bean is constructor-injected; strategies
and the factory are plain classes with no framework dependencies beyond
StringRedisTemplate. - Convention over configuration —
RateLimiterAutoConfiguration+META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsmake this a real, self-wiring Spring Boot starter; consuming applications never construct a rate-limiter bean by hand.
mvn clean installThis environment's sandbox network couldn't reach Maven Central to run a full
mvn clean installwhile generating this project, so please run the build locally to confirm everything resolves — the code has been written and reviewed carefully, but hasn't had an actual compiler pass over it. Ifjedis-mock'sRedisServerAPI differs slightly from what's used inRedisTestSupport/RateLimitIntegrationTestfor the pinned version (1.1.5), those two test-support spots are the only ones likely to need a small tweak.