java
51 lines · 8 steps
How a rate-limiting filter works in Spring
A per-client fixed-window rate limiter built as a Spring servlet filter with concurrent-safe counters.
Explained by
highlit
1@Component
2public class RateLimitingFilter extends OncePerRequestFilter {
3
4 private static final int MAX_REQUESTS = 100;
5 private static final Duration WINDOW = Duration.ofMinutes(1);
6
7 private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
8
9 @Override
10 protected void doFilterInternal(HttpServletRequest request,
11 HttpServletResponse response,
12 FilterChain filterChain)
13 throws ServletException, IOException {
14
15 String clientKey = resolveClientKey(request);
16 Bucket bucket = buckets.computeIfAbsent(clientKey,
17 key -> new Bucket(Instant.now(), new AtomicInteger()));
18
19 if (bucket.isExpired()) {
20 bucket = buckets.merge(clientKey,
21 new Bucket(Instant.now(), new AtomicInteger()),
22 (existing, fresh) -> existing.isExpired() ? fresh : existing);
23 }
24
25 int used = bucket.count().incrementAndGet();
26 long remaining = Math.max(0, MAX_REQUESTS - used);
27 response.setHeader("X-RateLimit-Limit", String.valueOf(MAX_REQUESTS));
28 response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));
29
30 if (used > MAX_REQUESTS) {
31 response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
32 response.setHeader("Retry-After", String.valueOf(WINDOW.getSeconds()));
33 response.setContentType(MediaType.APPLICATION_JSON_VALUE);
34 response.getWriter().write("{\"error\":\"rate limit exceeded\"}");
35 return;
36 }
37
38 filterChain.doFilter(request, response);
39 }
40
41 private String resolveClientKey(HttpServletRequest request) {
42 String apiKey = request.getHeader("X-Api-Key");
43 return apiKey != null ? apiKey : request.getRemoteAddr();
44 }
45
46 private record Bucket(Instant start, AtomicInteger count) {
47 boolean isExpired() {
48 return Instant.now().isAfter(start.plus(WINDOW));
49 }
50 }
51}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A fixed-window limiter tracks a start time and a count per client, resetting once the window elapses.
- 2ConcurrentHashMap's computeIfAbsent and merge keep bucket creation and rotation atomic under concurrent requests.
- 3Filters can short-circuit a request early by writing a response and returning without calling the chain.
Related explainers
java
package com.example.maintenance; import org.slf4j.Logger; import org.slf4j.LoggerFactory;
How a scheduled cleanup job runs in Spring
scheduling
cron
transactions
Intermediate
6 steps
java
public List<Employee> parseEmployees(Path csvPath) throws IOException { List<Employee> employees = new ArrayList<>(); try (BufferedReader reader = Files.newBufferedReader(csvPath, StandardCharsets.UTF_8)) {
Parsing a CSV into typed objects in Java
csv-parsing
file-io
try-with-resources
Intermediate
7 steps
java
public final class RetryExecutor { private final int maxAttempts; private final Duration initialDelay;
Exponential backoff retry in Java
retry
exponential-backoff
jitter
Intermediate
8 steps
java
import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.locks.ReentrantReadWriteLock;
A thread-safe LRU cache in Java
lru-cache
concurrency
linkedhashmap
Intermediate
7 steps
go
package metrics import ( "sync/atomic"
A lock-free request counter in Go
concurrency
atomics
lock-free
Intermediate
6 steps
java
public record ServerConfig(String host, int port, boolean tls, List<String> allowedOrigins) { public ServerConfig { Objects.requireNonNull(host, "host is required");
Validating config with a Java record
records
validation
immutability
Intermediate
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/how-a-rate-limiting-filter-works-in-spring-explained-java-5abd/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.