Skip to content
Home / Skills / Resilience / Rate Limiting
RE

Rate Limiting

Resilience core v1.0.0

Rate Limiting

Overview

Rate limiting controls the rate of requests to protect services from overload and ensure fair resource allocation. This skill covers rate limiting algorithms, implementation patterns, and distributed rate limiting.


Key Concepts

Rate Limiting Algorithms

┌─────────────────────────────────────────────────────────────┐
│               Rate Limiting Algorithms                       │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. Token Bucket:                                           │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Bucket capacity: 10 tokens                          │   │
│  │  Refill rate: 2 tokens/second                       │   │
│  │                                                      │   │
│  │  [●][●][●][●][○][○][○][○][○][○]                    │   │
│  │   4 tokens available                                │   │
│  │                                                      │   │
│  │  Request → Take 1 token (if available)              │   │
│  │  Allows bursts up to bucket capacity                │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  2. Sliding Window Log:                                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Window: 1 minute, Limit: 100 requests              │   │
│  │                                                      │   │
│  │  t-60s         t-30s          now                   │   │
│  │    │             │             │                     │   │
│  │    ▼             ▼             ▼                     │   │
│  │  ────[req1, req2, ... req85]────▶                   │   │
│  │                                                      │   │
│  │  85 requests in window → 15 remaining               │   │
│  │  Most accurate, high memory                         │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  3. Sliding Window Counter:                                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Previous window: 84 requests                       │   │
│  │  Current window: 36 requests                        │   │
│  │  Current position: 25% through window               │   │
│  │                                                      │   │
│  │  Weighted count = 84 * 0.75 + 36 = 99               │   │
│  │                                                      │   │
│  │  Memory efficient, slightly less accurate           │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  4. Fixed Window Counter:                                   │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  [Window 1: 98/100] [Window 2: 12/100] ...          │   │
│  │                                                      │   │
│  │  Simple, but allows bursts at window boundaries     │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Best Practices

1. Rate Limit at Multiple Levels

Global, per-user, per-endpoint.

2. Return Meaningful Headers

X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After.

3. Graceful Degradation

Serve cached or degraded responses when rate limited.

4. Configure Different Limits by Tier

Premium users get higher limits.

5. Monitor and Alert

Track rate limit hits and adjust accordingly.


Code Examples

Example 1: Token Bucket Implementation

public class TokenBucket {
    
    private final long capacity;
    private final double refillRate; // tokens per second
    private double tokens;
    private long lastRefillTime;
    
    public TokenBucket(long capacity, double refillRate) {
        this.capacity = capacity;
        this.refillRate = refillRate;
        this.tokens = capacity;
        this.lastRefillTime = System.nanoTime();
    }
    
    public synchronized boolean tryAcquire() {
        return tryAcquire(1);
    }
    
    public synchronized boolean tryAcquire(int count) {
        refill();
        
        if (tokens >= count) {
            tokens -= count;
            return true;
        }
        
        return false;
    }
    
    public synchronized long tryAcquireWithWait(int count, Duration maxWait) 
            throws InterruptedException {
        refill();
        
        if (tokens >= count) {
            tokens -= count;
            return 0; // No wait needed
        }
        
        // Calculate wait time
        double tokensNeeded = count - tokens;
        long waitMillis = (long) (tokensNeeded / refillRate * 1000);
        
        if (waitMillis > maxWait.toMillis()) {
            return -1; // Would exceed max wait
        }
        
        Thread.sleep(waitMillis);
        tokens = 0; // Used all tokens including newly refilled
        return waitMillis;
    }
    
    private void refill() {
        long now = System.nanoTime();
        double elapsed = (now - lastRefillTime) / 1_000_000_000.0;
        double newTokens = elapsed * refillRate;
        
        tokens = Math.min(capacity, tokens + newTokens);
        lastRefillTime = now;
    }
    
    public synchronized double getAvailableTokens() {
        refill();
        return tokens;
    }
}

Example 2: Resilience4j Rate Limiter

@Configuration
public class RateLimiterConfig {
    
    @Bean
    public RateLimiterRegistry rateLimiterRegistry() {
        io.github.resilience4j.ratelimiter.RateLimiterConfig defaultConfig =
            io.github.resilience4j.ratelimiter.RateLimiterConfig.custom()
                .limitForPeriod(100)
                .limitRefreshPeriod(Duration.ofSeconds(1))
                .timeoutDuration(Duration.ofMillis(500))
                .build();
        
        RateLimiterRegistry registry = RateLimiterRegistry.of(defaultConfig);
        
        // API endpoint limits
        registry.addConfiguration("api-standard",
            io.github.resilience4j.ratelimiter.RateLimiterConfig.custom()
                .limitForPeriod(60)
                .limitRefreshPeriod(Duration.ofMinutes(1))
                .build()
        );
        
        registry.addConfiguration("api-premium",
            io.github.resilience4j.ratelimiter.RateLimiterConfig.custom()
                .limitForPeriod(1000)
                .limitRefreshPeriod(Duration.ofMinutes(1))
                .build()
        );
        
        return registry;
    }
}

@RestController
@RequestMapping("/api")
public class RateLimitedController {
    
    private final RateLimiterRegistry rateLimiterRegistry;
    
    @GetMapping("/resource")
    public ResponseEntity<Resource> getResource(
            @RequestHeader("X-API-Key") String apiKey) {
        
        // Get rate limiter for this API key
        String tier = getUserTier(apiKey);
        RateLimiter rateLimiter = rateLimiterRegistry.rateLimiter(
            "api-" + apiKey,
            tier.equals("premium") ? "api-premium" : "api-standard"
        );
        
        // Check rate limit
        if (!rateLimiter.acquirePermission()) {
            RateLimiter.Metrics metrics = rateLimiter.getMetrics();
            
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                .header("X-RateLimit-Limit", 
                    String.valueOf(rateLimiter.getRateLimiterConfig().getLimitForPeriod()))
                .header("X-RateLimit-Remaining", 
                    String.valueOf(metrics.getAvailablePermissions()))
                .header("Retry-After", 
                    String.valueOf(metrics.getNanosToWait() / 1_000_000_000))
                .body(null);
        }
        
        // Process request
        Resource resource = resourceService.getResource();
        
        RateLimiter.Metrics metrics = rateLimiter.getMetrics();
        return ResponseEntity.ok()
            .header("X-RateLimit-Limit", 
                String.valueOf(rateLimiter.getRateLimiterConfig().getLimitForPeriod()))
            .header("X-RateLimit-Remaining", 
                String.valueOf(metrics.getAvailablePermissions()))
            .body(resource);
    }
}

Example 3: Distributed Rate Limiting with Redis

@Component
public class DistributedRateLimiter {
    
    private final RedisTemplate<String, String> redisTemplate;
    
    /**
     * Sliding window rate limiting with Redis
     */
    public boolean isAllowed(String key, int limit, Duration window) {
        String redisKey = "ratelimit:" + key;
        long now = System.currentTimeMillis();
        long windowStart = now - window.toMillis();
        
        // Use Lua script for atomicity
        String luaScript = """
            -- Remove old entries
            redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
            
            -- Count current entries
            local count = redis.call('ZCARD', KEYS[1])
            
            if count < tonumber(ARGV[2]) then
                -- Add new entry
                redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3])
                redis.call('EXPIRE', KEYS[1], ARGV[4])
                return 1
            else
                return 0
            end
            """;
        
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            List.of(redisKey),
            String.valueOf(windowStart),
            String.valueOf(limit),
            String.valueOf(now),
            String.valueOf(window.toSeconds())
        );
        
        return result != null && result == 1;
    }
    
    /**
     * Token bucket with Redis
     */
    public TokenBucketResult tryAcquireToken(String key, int capacity, double refillRate) {
        String luaScript = """
            local key = KEYS[1]
            local capacity = tonumber(ARGV[1])
            local refill_rate = tonumber(ARGV[2])
            local now = tonumber(ARGV[3])
            local requested = tonumber(ARGV[4])
            
            local data = redis.call('HMGET', key, 'tokens', 'last_refill')
            local tokens = tonumber(data[1]) or capacity
            local last_refill = tonumber(data[2]) or now
            
            -- Calculate tokens to add
            local elapsed = (now - last_refill) / 1000
            local new_tokens = math.min(capacity, tokens + elapsed * refill_rate)
            
            local allowed = 0
            if new_tokens >= requested then
                new_tokens = new_tokens - requested
                allowed = 1
            end
            
            redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
            redis.call('EXPIRE', key, 3600)
            
            return {allowed, math.floor(new_tokens)}
            """;
        
        List<Long> result = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, List.class),
            List.of("bucket:" + key),
            String.valueOf(capacity),
            String.valueOf(refillRate),
            String.valueOf(System.currentTimeMillis()),
            "1"
        );
        
        return new TokenBucketResult(
            result.get(0) == 1,
            result.get(1).intValue()
        );
    }
    
    /**
     * Get current rate limit status
     */
    public RateLimitStatus getStatus(String key, int limit, Duration window) {
        String redisKey = "ratelimit:" + key;
        long now = System.currentTimeMillis();
        long windowStart = now - window.toMillis();
        
        // Count entries in window
        Long count = redisTemplate.opsForZSet()
            .count(redisKey, windowStart, now);
        
        int remaining = Math.max(0, limit - (count != null ? count.intValue() : 0));
        long resetAt = now + window.toMillis();
        
        return new RateLimitStatus(limit, remaining, resetAt);
    }
}

record TokenBucketResult(boolean allowed, int remainingTokens) {}
record RateLimitStatus(int limit, int remaining, long resetAt) {}

Example 4: Multi-Level Rate Limiting

@Component
public class MultiLevelRateLimiter {
    
    private final DistributedRateLimiter distributedLimiter;
    private final Map<String, RateLimiter> localLimiters = new ConcurrentHashMap<>();
    
    /**
     * Check multiple rate limit levels
     */
    public RateLimitDecision checkRateLimit(String userId, String endpoint) {
        List<RateLimitCheck> checks = List.of(
            // Global limit
            new RateLimitCheck("global", 10000, Duration.ofSeconds(1)),
            // Per-user limit
            new RateLimitCheck("user:" + userId, 100, Duration.ofMinutes(1)),
            // Per-endpoint limit
            new RateLimitCheck("endpoint:" + endpoint, 1000, Duration.ofSeconds(1)),
            // User + endpoint limit
            new RateLimitCheck("user:" + userId + ":endpoint:" + endpoint, 10, Duration.ofSeconds(1))
        );
        
        for (RateLimitCheck check : checks) {
            if (!distributedLimiter.isAllowed(check.key(), check.limit(), check.window())) {
                return RateLimitDecision.denied(check.key(), check.limit(), check.window());
            }
        }
        
        return RateLimitDecision.allowed();
    }
    
    /**
     * Two-tier rate limiting: local + distributed
     */
    public boolean isAllowedTwoTier(String key, int localLimit, int distributedLimit, Duration window) {
        // First check local limiter (fast path)
        RateLimiter localLimiter = localLimiters.computeIfAbsent(key, k ->
            RateLimiter.of(k, io.github.resilience4j.ratelimiter.RateLimiterConfig.custom()
                .limitForPeriod(localLimit)
                .limitRefreshPeriod(window)
                .timeoutDuration(Duration.ZERO)
                .build())
        );
        
        if (!localLimiter.acquirePermission()) {
            return false;
        }
        
        // Then check distributed limiter (slow path, ensures cluster-wide limits)
        return distributedLimiter.isAllowed(key, distributedLimit, window);
    }
}

record RateLimitCheck(String key, int limit, Duration window) {}

record RateLimitDecision(
    boolean allowed,
    String violatedLimit,
    int limit,
    Duration window,
    Duration retryAfter
) {
    static RateLimitDecision allowed() {
        return new RateLimitDecision(true, null, 0, null, null);
    }
    
    static RateLimitDecision denied(String key, int limit, Duration window) {
        return new RateLimitDecision(false, key, limit, window, window);
    }
}

Example 5: Adaptive Rate Limiting

@Component
public class AdaptiveRateLimiter {
    
    private final Map<String, AdaptiveLimits> adaptiveLimits = new ConcurrentHashMap<>();
    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    
    @PostConstruct
    public void startAdaptation() {
        scheduler.scheduleAtFixedRate(this::adjustLimits, 10, 10, TimeUnit.SECONDS);
    }
    
    public boolean isAllowed(String key, int baseLimit) {
        AdaptiveLimits limits = adaptiveLimits.computeIfAbsent(key, 
            k -> new AdaptiveLimits(baseLimit));
        
        return limits.tryAcquire();
    }
    
    public void recordSuccess(String key) {
        AdaptiveLimits limits = adaptiveLimits.get(key);
        if (limits != null) {
            limits.recordSuccess();
        }
    }
    
    public void recordFailure(String key, FailureType type) {
        AdaptiveLimits limits = adaptiveLimits.get(key);
        if (limits != null) {
            limits.recordFailure(type);
        }
    }
    
    private void adjustLimits() {
        adaptiveLimits.forEach((key, limits) -> {
            double successRate = limits.getSuccessRate();
            double currentLimit = limits.getCurrentLimit();
            
            if (successRate > 0.99 && limits.isNotThrottled()) {
                // Very healthy - increase limit slowly
                limits.setCurrentLimit(Math.min(currentLimit * 1.1, limits.getMaxLimit()));
            } else if (successRate < 0.95) {
                // Degraded - decrease limit
                limits.setCurrentLimit(Math.max(currentLimit * 0.8, limits.getMinLimit()));
            }
            
            limits.resetCounters();
        });
    }
    
    enum FailureType {
        TIMEOUT, ERROR_5XX, CIRCUIT_OPEN
    }
}

class AdaptiveLimits {
    private final int baseLimit;
    private final AtomicInteger successCount = new AtomicInteger();
    private final AtomicInteger failureCount = new AtomicInteger();
    private final AtomicLong currentLimit;
    private final RateLimiter limiter;
    
    AdaptiveLimits(int baseLimit) {
        this.baseLimit = baseLimit;
        this.currentLimit = new AtomicLong(baseLimit);
        this.limiter = RateLimiter.create(baseLimit);
    }
    
    boolean tryAcquire() {
        return limiter.tryAcquire();
    }
    
    void recordSuccess() {
        successCount.incrementAndGet();
    }
    
    void recordFailure(AdaptiveRateLimiter.FailureType type) {
        failureCount.incrementAndGet();
    }
    
    double getSuccessRate() {
        int total = successCount.get() + failureCount.get();
        if (total == 0) return 1.0;
        return (double) successCount.get() / total;
    }
    
    double getCurrentLimit() {
        return currentLimit.get();
    }
    
    void setCurrentLimit(double limit) {
        currentLimit.set((long) limit);
        limiter.setRate(limit);
    }
    
    double getMaxLimit() {
        return baseLimit * 2;
    }
    
    double getMinLimit() {
        return baseLimit * 0.25;
    }
    
    boolean isNotThrottled() {
        return limiter.tryAcquire(0, TimeUnit.MILLISECONDS);
    }
    
    void resetCounters() {
        successCount.set(0);
        failureCount.set(0);
    }
}

Anti-Patterns

❌ Rate Limiting Without Headers

// WRONG - client has no visibility
return ResponseEntity.status(429).build();

// ✅ CORRECT - include rate limit info
return ResponseEntity.status(429)
    .header("X-RateLimit-Limit", "100")
    .header("X-RateLimit-Remaining", "0")
    .header("Retry-After", "30")
    .build();

❌ Fixed Window at Boundaries

Fixed window allows 2x burst at window boundaries; use sliding window.


References