Snippets/java/Rate Limiter Annotation
intermediatejava

Rate Limiter Annotation

A reusable AOP annotation to rate-limit API endpoints using a Token Bucket algorithm in Spring Boot.

Published December 2, 2025
Updated December 2, 2025
System Design
spring-bootaopperformanceconcurrency

Rate Limiter Annotation

A custom Spring Boot annotation to protect your API endpoints from abuse. This implementation uses Spring AOP to intercept method calls and enforces a rate limit using a thread-safe Token Bucket algorithm.

Highlights

  • Zero External Dependencies: Uses standard Java concurrency primitives (ConcurrentHashMap, ReentrantLock).
  • Configurable: Set requests per second/minute directly on the annotation.
  • IP-based Limiting: Automatically identifies clients based on their IP address (requires HttpServletRequest).

Code

package com.example.demo.aspect;
 
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
 
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
 
// 1. The Annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int limit() default 10;
    long duration() default 60;
    TimeUnit unit() default TimeUnit.SECONDS;
}
 
// 2. The Aspect
@Aspect
@Component
public class RateLimitAspect {
 
    private final Map<String, TokenBucket> buckets = new ConcurrentHashMap<>();
 
    @Around("@annotation(rateLimit)")
    public Object enforceRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String ip = request.getRemoteAddr();
        String key = ip + ":" + joinPoint.getSignature().toShortString();
 
        TokenBucket bucket = buckets.computeIfAbsent(key, k -> new TokenBucket(rateLimit.limit(), rateLimit.duration(), rateLimit.unit()));
 
        if (!bucket.tryConsume()) {
            throw new RateLimitExceededException("Too many requests. Please try again later.");
        }
 
        return joinPoint.proceed();
    }
 
    // Simple Token Bucket Implementation
    private static class TokenBucket {
        private final long capacity;
        private final long refillPeriodNanos;
        private double tokens;
        private long lastRefillTime;
 
        public TokenBucket(long capacity, long duration, TimeUnit unit) {
            this.capacity = capacity;
            this.refillPeriodNanos = unit.toNanos(duration) / capacity;
            this.tokens = capacity;
            this.lastRefillTime = System.nanoTime();
        }
 
        public synchronized boolean tryConsume() {
            refill();
            if (tokens >= 1) {
                tokens--;
                return true;
            }
            return false;
        }
 
        private void refill() {
            long now = System.nanoTime();
            long elapsed = now - lastRefillTime;
            if (elapsed > 0) {
                double newTokens = (double) elapsed / refillPeriodNanos;
                tokens = Math.min(capacity, tokens + newTokens);
                lastRefillTime = now;
            }
        }
    }
    
    // Custom Exception
    public static class RateLimitExceededException extends RuntimeException {
        public RateLimitExceededException(String message) {
            super(message);
        }
    }
}

Usage

Simply annotate your controller methods:

@RestController
@RequestMapping("/api")
public class ProductController {
 
    @GetMapping("/products")
    @RateLimit(limit = 5, duration = 1, unit = TimeUnit.MINUTES) // 5 requests per minute
    public List<Product> getProducts() {
        return productService.findAll();
    }
    
    @PostMapping("/orders")
    @RateLimit(limit = 1, duration = 10, unit = TimeUnit.SECONDS) // 1 request every 10 seconds
    public Order createOrder(@RequestBody Order order) {
        return orderService.create(order);
    }
}

[!NOTE] For distributed systems (e.g., multiple instances of your service), replace the in-memory ConcurrentHashMap with a distributed store like Redis (using Redisson or Lettuce).