Spring Boot cookbook · Intermediate

Rate limit a Spring Boot API with Redis

Limit each API client to N requests per minute across all instances and return 429 with Retry-After.

Approach

Implement a OncePerRequestFilter (or HandlerInterceptor) that increments a Redis counter per client and window with a Lua script (INCR + EXPIRE atomically). Identify clients by authenticated principal or API key, falling back to IP. Return a 429 Problem Detail with Retry-After when exceeded.

Solution

@Component
public class RateLimitFilter extends OncePerRequestFilter {
    private static final RedisScript<List> SCRIPT = RedisScript.of("""
            local c = redis.call('INCR', KEYS[1])
            if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
            return {c, redis.call('TTL', KEYS[1])}
            """, List.class);

    private final StringRedisTemplate redis;
    private final int limit = 100;
    private final int windowSeconds = 60;

    public RateLimitFilter(StringRedisTemplate redis) { this.redis = redis; }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String client = Optional.ofNullable(req.getUserPrincipal()).map(Principal::getName)
                .orElse("ip:" + req.getRemoteAddr());
        long window = Instant.now().getEpochSecond() / windowSeconds;
        List<?> result = redis.execute(SCRIPT, List.of("rl:" + client + ":" + window), String.valueOf(windowSeconds));
        long count = ((Number) result.get(0)).longValue();
        long ttl = ((Number) result.get(1)).longValue();

        res.setHeader("RateLimit-Limit", String.valueOf(limit));
        res.setHeader("RateLimit-Remaining", String.valueOf(Math.max(0, limit - count)));
        if (count > limit) {
            res.setStatus(429);
            res.setHeader("Retry-After", String.valueOf(Math.max(1, ttl)));
            res.setContentType("application/problem+json");
            res.getWriter().write("{\"title\":\"Too Many Requests\",\"status\":429}");
            return;
        }
        chain.doFilter(req, res);
    }
}

Pitfalls

  • In-memory buckets per instance (limits multiply with replicas).
  • getRemoteAddr() behind a proxy without server.forward-headers-strategy=native.
  • Filter order: run after authentication if limiting per user.

Variations

  • Bucket4j with a Redis/JCache backend for token-bucket semantics.
  • Spring Cloud Gateway's RequestRateLimiter filter at the edge.

Further reading: Spring Data Redis: Scripting

Reference: https://docs.spring.io/spring-data/redis/reference/redis/scripting.html

More Spring Boot recipes