Implementing API Rate Limiting in Spring Boot with AOP
This tutorial walks through why API rate limiting is a critical first line of defense for backend services, compares fixed‑window and sliding‑window strategies, and shows how to create a custom @RateLimit annotation, utility classes, an AOP aspect, Redis or local storage, and unified exception handling, providing complete code and test scenarios for Spring Boot 2.7.x.
1. Core Scenarios for API Rate Limiting
Backend developers know that rate limiting is the "first line of defense" for system stability. Login, SMS verification, and payment interfaces can be abused by malicious requests, causing slow responses, service crashes, or extra costs.
Writing rate‑limit logic in every controller leads to code duplication and hard‑to‑maintain logic. Using AOP, a single annotation can control request frequency without touching business code.
Multiple Rate‑Limit Strategies : Fixed‑window (simple) and sliding‑window (precise, avoids edge cases).
Custom Rate‑Limit Key : Limit by IP or by user ID.
Custom Parameters : Configure time window and max request count (e.g., 10 requests per minute).
Unified Response : Return a standard JSON with error code and message when throttled.
Non‑Intrusive : AOP enhancement keeps business interfaces unchanged.
Distributed Support : Local cache for single‑node, Redis for clustered deployments.
Exception Handling : Rate‑limit failures do not affect normal access.
2. Design Overview
Before coding, understand the two core strategies and the overall design to avoid tangled logic.
2.1 Fixed‑Window Rate Limiting
Principle: Divide time into fixed windows (e.g., one minute). Count requests in the current window; exceed the limit → throttle.
Example: With "max 10 requests per minute", the first window (0‑60 s) allows 10 requests, then blocks until the next window starts.
Pros: Simple, high performance. Cons: Edge case – 59 s → 10 requests, 61 s → 10 requests, resulting in 20 requests within 2 s.
2.2 Sliding‑Window Rate Limiting
Principle: Split a fixed window into smaller sub‑windows (e.g., 6 × 10 s). For each request, count the requests in the last 60 s.
Example: With the same "10 per minute" rule, 10 requests at 59 s and a request at 61 s are still counted as 10, preventing the edge case.
Pros: Precise, no edge case. Cons: Slightly more complex, marginally lower performance.
2.3 Overall Design
Custom Annotation : @RateLimit marks methods that need throttling and configures strategy, key type, time window, max count, message, and storage.
Utility Class : Implements fixed‑window and sliding‑window logic, supports local ConcurrentHashMap and Redis storage.
AOP Aspect : Defines a pointcut on methods annotated with @RateLimit, retrieves annotation values, generates a unique key, invokes the appropriate limit check, and throws RateLimitException on violation.
Key Generation : Based on KeyType, builds ip:xxx:method or user:xxx:method keys.
Unified Exception & Response : RateLimitException (HTTP 429) is caught by a global handler that returns a JSON with code, msg, and data.
3. Full Code
The implementation targets SpringBoot 2.7.x and integrates Redis for distributed throttling. All code includes detailed comments for beginners.
Step 1: Core Dependencies (pom.xml)
<!-- Spring AOP core dependency (rate‑limit core) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Redis dependency (required for distributed rate‑limit, optional for single‑node) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Utility libraries (JSON response, cache ops) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.32</version>
</dependency>
<!-- Lombok (simplify POJOs) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>Step 2: Configuration (application.yml)
server:
port: 8080
# Redis configuration (required for distributed rate‑limit)
spring:
redis:
host: localhost
port: 6379
password: # leave empty if no password
database: 0
lettuce:
pool:
max-active: 100
max-idle: 10
min-idle: 5
# Global rate‑limit defaults (can be overridden per annotation)
rate-limit:
default-time: 60 # seconds
default-count: 10 # max requests
default-type: FIXED_WINDOW
default-key-type: IPStep 3: Custom Annotation
import java.lang.annotation.*;
/**
* Custom API rate‑limit annotation
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/** Rate‑limit strategy */
LimitType type() default LimitType.FIXED_WINDOW;
/** Key type */
KeyType keyType() default KeyType.IP;
/** Time window (seconds) */
int time() default 0;
/** Max requests */
int count() default 0;
/** Message returned when throttled */
String message() default "请求过于频繁,请稍后再试!";
/** Storage type */
StoreType storeType() default StoreType.REDIS;
enum LimitType { FIXED_WINDOW, SLIDING_WINDOW }
enum KeyType { IP, USER_ID }
enum StoreType { LOCAL, REDIS }
}Step 4: Rate‑Limit Constants
/**
* Constants for rate‑limit keys and sliding‑window interval
*/
public class RateLimitConstant {
public static final String RATE_LIMIT_KEY_PREFIX = "rate_limit:";
public static final int SLIDING_WINDOW_INTERVAL = 10; // seconds
}Step 5: Rate‑Limit Utility (core logic)
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/**
* Implements fixed‑window and sliding‑window rate limiting, supports local and Redis storage.
*/
@Slf4j
@Component
public class RateLimitUtil {
// Local cache for single‑node
private final ConcurrentHashMap<String, Integer> localCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> localWindowCache = new ConcurrentHashMap<>();
// Redis template (optional for single‑node)
@Autowired(required = false)
private StringRedisTemplate stringRedisTemplate;
/** Fixed‑window limit */
public boolean fixedWindowLimit(String key, int time, int count, RateLimit.StoreType storeType) {
if (storeType == RateLimit.StoreType.LOCAL) {
return localFixedWindowLimit(key, time, count);
} else {
return redisFixedWindowLimit(key, time, count);
}
}
/** Sliding‑window limit */
public boolean slidingWindowLimit(String key, int time, int count, RateLimit.StoreType storeType) {
if (storeType == RateLimit.StoreType.LOCAL) {
return localSlidingWindowLimit(key, time, count);
} else {
return redisSlidingWindowLimit(key, time, count);
}
}
// ----- Local implementations -----
private boolean localFixedWindowLimit(String key, int time, int count) {
Integer current = localCache.getOrDefault(key, 0);
if (current >= count) {
log.warn("Local fixed‑window limit triggered, key:{}, count:{}", key, current);
return true;
}
if (current == 0) {
localWindowCache.put(key, System.currentTimeMillis() + time * 1000L);
} else {
Long expire = localWindowCache.get(key);
if (expire != null && System.currentTimeMillis() > expire) {
localCache.put(key, 1);
localWindowCache.put(key, System.currentTimeMillis() + time * 1000L);
return false;
}
}
localCache.put(key, current + 1);
return false;
}
private boolean localSlidingWindowLimit(String key, int time, int count) {
long now = System.currentTimeMillis();
long windowStart = now - time * 1000L;
String windowKey = key + ":" + (now / (RateLimitConstant.SLIDING_WINDOW_INTERVAL * 1000L));
Integer cur = localCache.getOrDefault(windowKey, 0);
int total = 0;
for (String k : localCache.keySet()) {
if (k.startsWith(key + ":")) {
long winTime = Long.parseLong(k.split(":")[2]) * RateLimitConstant.SLIDING_WINDOW_INTERVAL * 1000L;
if (winTime >= windowStart) {
total += localCache.get(k);
} else {
localCache.remove(k);
}
}
}
if (total >= count) {
log.warn("Local sliding‑window limit triggered, key:{}, total:{}", key, total);
return true;
}
localCache.put(windowKey, cur + 1);
return false;
}
// ----- Redis implementations -----
private boolean redisFixedWindowLimit(String key, int time, int count) {
String redisKey = RateLimitConstant.RATE_LIMIT_KEY_PREFIX + key;
Long cur = stringRedisTemplate.opsForValue().increment(redisKey, 1);
if (cur != null && cur == 1) {
stringRedisTemplate.expire(redisKey, time, TimeUnit.SECONDS);
}
if (cur != null && cur > count) {
log.warn("Redis fixed‑window limit triggered, key:{}, count:{}", redisKey, cur);
return true;
}
return false;
}
private boolean redisSlidingWindowLimit(String key, int time, int count) {
long now = System.currentTimeMillis();
long windowStart = now - time * 1000L;
String redisKey = RateLimitConstant.RATE_LIMIT_KEY_PREFIX + key;
int interval = RateLimitConstant.SLIDING_WINDOW_INTERVAL;
long currentWindow = now / (interval * 1000L);
String lua = "local key = KEYS[1]
" +
"local windowStart = ARGV[1]
" +
"local currentWindow = ARGV[2]
" +
"local interval = ARGV[3]
" +
"local count = ARGV[4]
" +
"redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
" +
"local total = redis.call('ZCARD', key)
" +
"if total >= tonumber(count) then return 1 end
" +
"redis.call('ZADD', key, currentWindow, currentWindow .. ':' .. redis.call('INCR', key .. ':seq'))
" +
"redis.call('EXPIRE', key, tonumber(interval) + 1)
" +
"return 0";
Long result = stringRedisTemplate.execute(
new org.springframework.data.redis.core.script.DefaultRedisScript<>(lua, Long.class),
java.util.Collections.singletonList(redisKey),
String.valueOf(windowStart),
String.valueOf(currentWindow),
String.valueOf(interval),
String.valueOf(count)
);
if (result != null && result == 1) {
log.warn("Redis sliding‑window limit triggered, key:{}, count:{}", redisKey, count);
return true;
}
return false;
}
/** Clear limit cache (e.g., on logout) */
public void clearLimitCache(String key, RateLimit.StoreType storeType) {
if (storeType == RateLimit.StoreType.LOCAL) {
localCache.keySet().removeIf(k -> k.startsWith(key) || k.equals(key));
localWindowCache.remove(key);
} else {
String redisKey = RateLimitConstant.RATE_LIMIT_KEY_PREFIX + key;
stringRedisTemplate.delete(redisKey);
stringRedisTemplate.delete(redisKey + ":seq");
}
}
}Step 6: AOP Aspect
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
/**
* API rate‑limit aspect (core class)
*/
@Aspect
@Component
@Order(1)
@Slf4j
public class RateLimitAspect {
@Resource
private RateLimitUtil rateLimitUtil;
@Value("${rate-limit.default-time}")
private int defaultTime;
@Value("${rate-limit.default-count}")
private int defaultCount;
@Value("${rate-limit.default-type}")
private RateLimit.LimitType defaultLimitType;
@Value("${rate-limit.default-key-type}")
private RateLimit.KeyType defaultKeyType;
@Pointcut("@annotation(com.example.demo.annotation.RateLimit)")
public void rateLimitPointcut() {}
@Around("rateLimitPointcut()")
public Object doRateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit anno = method.getAnnotation(RateLimit.class);
RateLimit.LimitType limitType = anno.type() == RateLimit.LimitType.FIXED_WINDOW ?
anno.type() : defaultLimitType;
RateLimit.KeyType keyType = anno.keyType() == RateLimit.KeyType.IP ?
anno.keyType() : defaultKeyType;
int time = anno.time() == 0 ? defaultTime : anno.time();
int count = anno.count() == 0 ? defaultCount : anno.count();
String message = anno.message();
RateLimit.StoreType storeType = anno.storeType();
String limitKey = generateLimitKey(joinPoint, keyType);
log.info("Rate limit check, key:{}, strategy:{}, window:{}s, max:{}", limitKey, limitType, time, count);
boolean isLimit = false;
if (limitType == RateLimit.LimitType.FIXED_WINDOW) {
isLimit = rateLimitUtil.fixedWindowLimit(limitKey, time, count, storeType);
} else if (limitType == RateLimit.LimitType.SLIDING_WINDOW) {
isLimit = rateLimitUtil.slidingWindowLimit(limitKey, time, count, storeType);
}
if (isLimit) {
throw new RateLimitException(429, message);
}
return joinPoint.proceed();
}
private String generateLimitKey(ProceedingJoinPoint joinPoint, RateLimit.KeyType keyType) {
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
String methodName = sig.getDeclaringTypeName() + "." + sig.getMethod().getName();
if (keyType == RateLimit.KeyType.IP) {
String ip = IpUtil.getClientIp();
return "ip:" + ip + ":" + methodName;
} else if (keyType == RateLimit.KeyType.USER_ID) {
Long userId = UserContext.getCurrentUserId();
if (userId == null) {
String ip = IpUtil.getClientIp();
return "ip:" + ip + ":" + methodName;
}
return "user:" + userId + ":" + methodName;
}
String ip = IpUtil.getClientIp();
return "ip:" + ip + ":" + methodName;
}
}Step 7: Supporting Utilities
IP extraction, user context (simulated), custom exception, and global exception handler.
// IpUtil.java (extract real client IP, handle proxies)
public class IpUtil {
public static String getClientIp() {
// implementation omitted for brevity (same as source)
return "127.0.0.1";
}
}
// UserContext.java (mock current user ID)
public class UserContext {
public static Long getCurrentUserId() {
return 1001L; // mock logged‑in user
}
}
// RateLimitException.java
@Data
@EqualsAndHashCode(callSuper = true)
public class RateLimitException extends RuntimeException {
private Integer code;
private String message;
public RateLimitException(Integer code, String message) {
super(message);
this.code = code;
this.message = message;
}
}
// GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RateLimitException.class)
public JSONObject handleRateLimitException(RateLimitException e) {
JSONObject resp = new JSONObject();
resp.put("code", e.getCode());
resp.put("msg", e.getMessage());
resp.put("data", null);
return resp;
}
@ExceptionHandler(Exception.class)
public JSONObject handleException(Exception e) {
JSONObject resp = new JSONObject();
resp.put("code", 500);
resp.put("msg", "服务器内部异常,请联系管理员");
resp.put("data", null);
return resp;
}
}Step 8: Example Controller Usage
@RestController
@RequestMapping("/api")
public class TestController {
// SMS sending – IP fixed‑window, 1 min max 3 requests
@RateLimit(type = RateLimit.LimitType.FIXED_WINDOW, keyType = RateLimit.KeyType.IP,
time = 60, count = 3, message = "短信发送过于频繁,请1分钟后再试!")
@PostMapping("/sms/send")
public String sendSms(String phone) {
return "短信已发送至:" + phone;
}
// Login – IP sliding‑window, 10 s max 2 requests
@RateLimit(type = RateLimit.LimitType.SLIDING_WINDOW, keyType = RateLimit.KeyType.IP,
time = 10, count = 2, message = "登录请求过于频繁,请10秒后再试!")
@PostMapping("/auth/login")
public String login(String username, String password) {
return "登录成功,欢迎您:" + username;
}
// User profile – USER_ID fixed‑window, 1 min max 10 requests
@RateLimit(type = RateLimit.LimitType.FIXED_WINDOW, keyType = RateLimit.KeyType.USER_ID,
time = 60, count = 10, message = "操作过于频繁,请1分钟后再试!")
@GetMapping("/user/profile")
public String userProfile() {
Long userId = UserContext.getCurrentUserId();
return "用户ID:" + userId + ",个人信息查询成功";
}
// Distributed test – Redis sliding‑window, 5 s max 5 requests
@RateLimit(type = RateLimit.LimitType.SLIDING_WINDOW, keyType = RateLimit.KeyType.IP,
time = 5, count = 5, storeType = RateLimit.StoreType.REDIS,
message = "请求过于频繁,请5秒后再试!")
@GetMapping("/test/distributed")
public String distributedLimit() {
return "分布式限流测试成功";
}
}4. Testing Verification
Using Postman, the following core scenarios were validated:
SMS interface (fixed‑window, IP) – 4 rapid requests within a minute trigger the 4th request with HTTP 429.
Login interface (sliding‑window, IP) – 3 requests at 0 s, 5 s, 8 s result in the 3rd request being throttled, confirming no edge‑case burst.
User profile (USER_ID fixed‑window) – 11 requests within a minute throttle the 11th.
Distributed limit (Redis sliding‑window) – Two nodes (ports 8080 & 8081) together exceed 5 requests in 5 s, demonstrating unified throttling across the cluster.
Conclusion
Implementing API rate limiting with SpringBoot and AOP provides a clean, non‑intrusive solution that can be configured per endpoint via a single annotation. It supports both simple fixed‑window and precise sliding‑window algorithms, works in single‑node or clustered environments through local cache or Redis, and returns a unified JSON response for front‑end handling.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
