Spring Boot API Rate Limiting

Protect your REST APIs from abuse using the Token Bucket Algorithm, implemented with Bucket4j and distributed via Redis in Spring Boot 3.x.

Spring Boot 3.xJava 17+Bucket4jRedisMavenLombok

Overview & Dependencies

This example demonstrates a production-ready implementation of API rate limiting using Spring Boot 3.x, Bucket4j, and Redis.

Why it's important: Rate limiting protects your system from DDoS attacks, brute-force login attempts, and general abuse by malicious actors or misconfigured scripts. It's a critical component in System Design interviews and real-world microservices.

The Token Bucket Algorithm

Bucket4j uses the Token Bucket Algorithm. Imagine a bucket that holds a maximum number of tokens (capacity). Tokens are added to the bucket at a fixed rate (refill rate). Every time a request comes in, it must take a token from the bucket. If the bucket is empty, the request is rejected (HTTP 429 Too Many Requests). This handles both steady traffic and short bursts smoothly.

pom.xml Dependencies

Add the required dependencies for Spring Web, Redis (Lettuce), and Bucket4j's Redis integration.

pom.xml
<!-- Spring Web -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Redis for Distributed Caching -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Bucket4j Core and Redis Integration -->
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.10.1</version>
</dependency>
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-redis</artifactId>
    <version>8.10.1</version>
</dependency>

<!-- Lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

Redis & App Configuration

For a distributed system (multiple instances of your Spring Boot app), in-memory rate limiting fails because each instance has its own bucket. Redis solves this by acting as a centralized datastore for bucket states.

docker-compose.yml

docker-compose.yml
version: '3.8'
services:
  redis:
    image: redis:alpine
    container_name: rate-limit-redis
    ports:
      - "6379:6379"
    command: redis-server --appendonly yes

  # Optional: Your app config if running in docker
  # app:
  #   build: .
  #   ports:
  #     - "8080:8080"
  #   depends_on:
  #     - redis
  #   environment:
  #     - SPRING_DATA_REDIS_HOST=redis
  #     - SPRING_DATA_REDIS_PORT=6379

application.yml

src/main/resources/application.yml
server:
  port: 8080

spring:
  application:
    name: rate-limiting-api
  data:
    redis:
      host: localhost
      port: 6379
      # password: your-password-if-set

# Custom configuration for dynamic limits (optional extension)
app:
  rate-limit:
    public:
      capacity: 5
      refill-tokens: 5
      refill-duration-seconds: 60
    user:
      capacity: 20
      refill-tokens: 20
      refill-duration-seconds: 60

Rate Limit Configuration

Configure Bucket4j to use Redis as its backend. We use Lettuce (Spring's default Redis client) to create a generic `ProxyManager` that Bucket4j uses to interact with Redis.

src/main/java/com/example/config/RateLimitConfig.java
package com.example.config;

import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

@Configuration
public class RateLimitConfig {

    @Bean
    public RedisClient redisClient() {
        // Connect to Redis (Update URI if using passwords or external host)
        return RedisClient.create("redis://localhost:6379");
    }

    @Bean
    public ProxyManager<byte[]> proxyManager(RedisClient redisClient) {
        // Create the proxy manager that Bucket4j uses to interact with Redis
        return LettuceBasedProxyManager.builderFor(redisClient)
                .withExpirationStrategy(
                        io.github.bucket4j.distributed.ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(10))
                )
                .build();
    }
}

Rate Limit Service

This service handles the creation and retrieval of buckets for specific keys (e.g., IP addresses or User IDs). We define different policies based on the context (Public API vs User-specific API vs Admin API).

src/main/java/com/example/service/RateLimitService.java
package com.example.service;

import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.distributed.BucketProxy;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.function.Supplier;

@Service
@RequiredArgsConstructor
public class RateLimitService {

    private final ProxyManager<byte[]> proxyManager;

    /**
     * Resolves the bucket for a given key and plan type.
     */
    public BucketProxy resolveBucket(String key, Plan plan) {
        Supplier<BucketConfiguration> configSupplier = getConfigSupplierForPlan(plan);
        // Uses Redis backend to retrieve or build the bucket
        return proxyManager.builder().build(key.getBytes(), configSupplier);
    }

    private Supplier<BucketConfiguration> getConfigSupplierForPlan(Plan plan) {
        return () -> {
            switch (plan) {
                case PUBLIC:
                    // 5 requests per minute
                    return BucketConfiguration.builder()
                            .addLimit(limit -> limit.capacity(5).refillGreedy(5, Duration.ofMinutes(1)))
                            .build();
                case USER:
                    // 20 requests per minute
                    return BucketConfiguration.builder()
                            .addLimit(limit -> limit.capacity(20).refillGreedy(20, Duration.ofMinutes(1)))
                            .build();
                case ADMIN:
                    // 100 requests per minute (higher quota)
                    return BucketConfiguration.builder()
                            .addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofMinutes(1)))
                            .build();
                default:
                    return BucketConfiguration.builder()
                            .addLimit(limit -> limit.capacity(1).refillGreedy(1, Duration.ofMinutes(1)))
                            .build();
            }
        };
    }

    public enum Plan {
        PUBLIC, USER, ADMIN
    }
}

💡 Note on "refillGreedy": Greedy refill adds tokens continuously based on elapsed time (e.g., if refill rate is 60 tokens/minute, it adds 1 token per second). "refillIntervally" would add all 60 tokens exactly at the 1-minute mark.

Rate Limit Filter

The Filter intercepts all incoming HTTP requests. It identifies the client (by IP or User ID from JWT context), applies the rate limit logic, and either lets the request proceed or immediately rejects it with a `429 Too Many Requests` status, adding helpful retry headers.

src/main/java/com/example/filter/RateLimitFilter.java
package com.example.filter;

import com.example.service.RateLimitService;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.distributed.BucketProxy;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@RequiredArgsConstructor
public class RateLimitFilter extends OncePerRequestFilter {

    private final RateLimitService rateLimitService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String uri = request.getRequestURI();

        // Example logic: Skip public endpoints that don't need limiting
        if (uri.startsWith("/api/public")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Determine Plan and Key based on URI or Security Context
        RateLimitService.Plan plan = RateLimitService.Plan.PUBLIC;
        String key = getClientIP(request); // Default to IP based

        if (uri.startsWith("/api/user")) {
            plan = RateLimitService.Plan.USER;
            // In a real app with JWT, extract User ID from SecurityContextHolder here
            // String userId = SecurityContextHolder.getContext().getAuthentication().getName();
            // key = "user:" + userId;
        } else if (uri.startsWith("/api/admin")) {
            plan = RateLimitService.Plan.ADMIN;
            // key = "admin:" + adminId;
        } else if (uri.startsWith("/api/limited")) {
            plan = RateLimitService.Plan.PUBLIC;
            key = "ip:" + getClientIP(request);
        }

        // Resolve bucket and try to consume 1 token
        BucketProxy bucket = rateLimitService.resolveBucket(key, plan);
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            // Request allowed
            response.addHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
            log.debug("Request allowed for key: {}, remaining tokens: {}", key, probe.getRemainingTokens());
            filterChain.doFilter(request, response);
        } else {
            // Request rejected - rate limit exceeded
            long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.addHeader("X-Rate-Limit-Retry-After", String.valueOf(waitForRefill));

            response.setStatus(429); // Too Many Requests
            response.setContentType("application/json");

            String jsonResponse = String.format(
                "{"status": 429, "message": "Too many requests. Please try again later.", "retryAfterSeconds": %d}",
                waitForRefill
            );
            response.getWriter().write(jsonResponse);

            log.warn("Rate limit exceeded for key: {}. Retry after {} seconds.", key, waitForRefill);
        }
    }

    private String getClientIP(HttpServletRequest request) {
        String xfHeader = request.getHeader("X-Forwarded-For");
        if (xfHeader == null) {
            return request.getRemoteAddr();
        }
        return xfHeader.split(",")[0];
    }
}

API Controller

Finally, define your REST endpoints. Note how clean the controllers are—they don't need to know anything about the rate limiting logic, as the Filter handles it transversally.

src/main/java/com/example/controller/ApiController.java
package com.example.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class ApiController {

    @GetMapping("/public")
    public ResponseEntity<Map<String, String>> publicEndpoint() {
        return ResponseEntity.ok(Map.of(
            "message", "This endpoint is entirely public and has no rate limits applied."
        ));
    }

    @GetMapping("/limited")
    public ResponseEntity<Map<String, String>> limitedEndpoint() {
        return ResponseEntity.ok(Map.of(
            "message", "This endpoint is limited by IP. Max 5 requests per minute."
        ));
    }

    @GetMapping("/user")
    public ResponseEntity<Map<String, String>> userEndpoint() {
        return ResponseEntity.ok(Map.of(
            "message", "This endpoint is limited by User ID context. Max 20 requests per minute."
        ));
    }

    @GetMapping("/admin")
    public ResponseEntity<Map<String, String>> adminEndpoint() {
        return ResponseEntity.ok(Map.of(
            "message", "Admin endpoint. Higher quota applied."
        ));
    }
}

Postman Testing & Flow Analysis

Request Flow Diagram

Client Request → RateLimitFilter
  ↳ Extract IP/User ID
  ↳ Check Redis Bucket state via RateLimitService
    ├─ [Tokens > 0] → Consume Token → Proceed to ApiController → HTTP 200 OK
    └─ [Tokens = 0] → Reject early → HTTP 429 Too Many Requests

Postman Examples

1. Hitting `/api/limited` for the first time:

Response - HTTP 200 OK
// Headers
X-Rate-Limit-Remaining: 4

// Body
{
  "message": "This endpoint is limited by IP. Max 5 requests per minute."
}

2. Hitting `/api/limited` for the 6th time within a minute:

Response - HTTP 429 Too Many Requests
// Headers
X-Rate-Limit-Retry-After: 45

// Body
{
  "status": 429,
  "message": "Too many requests. Please try again later.",
  "retryAfterSeconds": 45
}

Why use Redis instead of ConcurrentHashMap?

  • Scalability: In a Kubernetes cluster with 5 pods of this API, an in-memory map limits users per-pod. Redis centralizes the bucket count across the entire cluster.
  • Persistence (Optional): Redis can persist rate-limit data across application restarts.
  • Atomic Operations: Bucket4j utilizes Redis LUA scripts under the hood to perform atomic token consumption, preventing race conditions during burst traffic.