Elegant API Rate Limiting with Spring Interceptor, Redis & Custom Annotations
This article demonstrates building an elegant API rate limiter in Spring Boot using interceptors and Redis, evolving from a basic fixed-window implementation to a flexible annotation-driven design supporting per-endpoint limits, class-level defaults, and solutions for path parameters and sliding window logic.
Overview
The article presents a step-by-step evolution of an API rate-limiting (anti-brush) solution for Spring Boot applications, using HandlerInterceptor and Redis for distributed counting. The author starts with a naive implementation and iteratively addresses shortcomings: global configuration, inflexible path mapping, lack of per-endpoint limits, time-window logic flaws, path-parameter collisions, and real IP detection behind proxies.
1. Basic Implementation: Interceptor + Redis Fixed Window
The core idea: combine client IP and request URI as a Redis key ( COUNT:IP:URI). In preHandle, check if a lock key ( LOCK:IP:URI) exists; if not, increment the counter with a TTL (e.g., 10 seconds). If the count exceeds a threshold (e.g., 3), set a lock key with a longer TTL (e.g., 60 seconds), delete the counter, and throw a CommonException(ResultCode.ACCESS_FREQUENT). Configuration values (window seconds, max requests, lock duration) are injected via @Value from properties.
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Value("${interfaceAccess.second}")
private Long second = 10L;
@Value("${interfaceAccess.time}")
private Long time = 3L;
@Value("${interfaceAccess.lockTime}")
private Long lockTime = 60L;
public static final String LOCK_PREFIX = "LOCK";
public static final String COUNT_PREFIX = "COUNT";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
String ip = request.getRemoteAddr(); // assumes direct access, no proxy
String lockKey = LOCK_PREFIX + ip + uri;
Object isLock = redisTemplate.opsForValue().get(lockKey);
if (Objects.isNull(isLock)) {
String countKey = COUNT_PREFIX + ip + uri;
Object count = redisTemplate.opsForValue().get(countKey);
if (Objects.isNull(count)) {
log.info("首次访问");
redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
} else {
if ((Integer) count < time) {
redisTemplate.opsForValue().increment(countKey);
} else {
log.info("{}禁用访问{}", ip, uri);
redisTemplate.opsForValue().set(lockKey, 1, lockTime, TimeUnit.SECONDS);
redisTemplate.delete(countKey);
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
} else {
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
return true;
}
}Global exception handling via @ControllerAdvice converts the exception to a JSON error response.
2. Selective Endpoint Protection via Interceptor Path Mapping
Register the interceptor with specific path patterns (e.g., /pass/**) so only designated endpoints are rate-limited. However, all matched endpoints share the same second, time, lockTime values — inflexible for endpoints with different performance characteristics.
3. Custom Annotation + Reflection for Per-Endpoint Limits
Define @AccessLimit with attributes second, maxTime, forbiddenTime. Annotate controller methods. In the interceptor, cast handler to HandlerMethod, retrieve the annotation via getMethodAnnotation(), and use its values. If absent, skip rate limiting for that endpoint.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLimit {
long second() default 10;
long maxTime() default 3;
long forbiddenTime() default 60;
}Interceptor logic extracts annotation values dynamically, enabling per-endpoint tuning.
4. Class-Level Annotation with Method Override (Version 2.0)
Modify @AccessLimit to target both TYPE and METHOD. In the interceptor, first check the declaring class for the annotation (
targetMethod.getMethod().getDeclaringClass().getAnnotation(AccessLimit.class)). If present, all methods in that class inherit those limits. Then check the method-level annotation; if both exist, method values take precedence (nearest-wins). A helper method isForbidden(...) encapsulates the Redis check-and-increment logic to avoid duplication.
private boolean isForbidden(long second, long maxTime, long forbiddenTime, String ip, String uri) {
String lockKey = LOCK_PREFIX + ip + uri;
Object isLock = redisTemplate.opsForValue().get(lockKey);
if (Objects.isNull(isLock)) {
String countKey = COUNT_PREFIX + ip + uri;
Object count = redisTemplate.opsForValue().get(countKey);
if (Objects.isNull(count)) {
log.info("首次访问");
redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
} else {
if ((Integer) count < maxTime) {
redisTemplate.opsForValue().increment(countKey);
} else {
log.info("{}禁用访问{}", ip, uri);
redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
redisTemplate.delete(countKey);
return true;
}
}
} else {
return true;
}
return false;
}5. Time-Window Logic Flaw: Fixed Window vs. Sliding Window
The author identifies a subtle bug: the fixed-window approach resets the counter at the window boundary, allowing bursts at window edges. Example: 5-second window, max 3 requests. Requests at 2s, 7s, 8s, 9s — the 2s request starts a window expiring at 7s; the 7s request sees the counter (1) and increments to 2, then the key expires immediately. Subsequent requests start new windows, never hitting the limit. A true sliding window (e.g., using Redis sorted sets with timestamps) would count requests in the last 5 seconds continuously. The author acknowledges the issue but leaves it unresolved.
6. Path Parameter Problem
Endpoints with path variables (e.g., /pass/{id}) produce different URIs for different IDs ( /pass/1, /pass/2), causing the IP+URI key to treat them as separate endpoints, bypassing the limit. Two solutions: (1) avoid path parameters (use query params), or (2) replace the URI in the key with a stable identifier — the fully qualified method name ( ClassName.methodName) obtained via reflection from HandlerMethod.
7. Real IP Behind Proxy
The current code uses request.getRemoteAddr(), which returns the proxy IP when behind a load balancer or gateway. The author notes the need to inspect headers like X-Forwarded-For or X-Real-IP but defers implementation.
Project Structure & Testing
The complete code is available at https://github.com/Tonciy/interface-brush-protection. The repository uses Git tags for each stage (e.g., "【初始化】", "【Interceptor设置映射规则实现接口自由】", "【自定义注解+反射实现接口自由-版本2.0】") so readers can check out and test each iteration. API documentation is provided via Apifox (password: Lyh3j2Rv).
Key Takeaways
Rate limiting evolves from global fixed config to annotation-driven per-endpoint flexibility.
Reflection on HandlerMethod enables declarative configuration without invasive code changes.
Class-level annotations with method override reduce boilerplate for controllers with uniform limits.
Fixed-window algorithms have edge-case burst vulnerabilities; sliding-window or token-bucket algorithms are more accurate.
Path variables break URI-based keys; use method signatures instead.
Real client IP extraction requires proxy-aware header parsing.
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.
Top Architect
Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.
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.
