feat: Rate Limiting Pattern #2973 (#3291)

* need to fix one test case shouldGraduallyIncreaseLimitWhenHealthy failing for AdaptiveRateLimiter.java

* Added Class Diagram and Flow Diagrams for Adaptive, Fixed Window and Token Bucket Rate Limiter

* Updated README.md. All test case passed. Updated with Google Java Guidelines

* Updated parent pom #2973

* Updated parent pom #2973

* fixed shouldResetCounterAfterWindow() test #2973

* formatting fixed #2973

* added test coverage for app.java and fixed random to be thread safe #2973

* added test coverage for app.java and fixed random to be thread safe #2973

* added test coverage for app.java and fixed random to be thread safe #2973

* added test coverage for app.java and fixed random to be thread safe #2973

* added test coverage for app.java and fixed random to be thread safe #2973

* added test coverage for app.java and fixed random to be thread safe #2973

* added test coverage for app.java and fixed random to be thread safe #2973

* fixed random to be thread safe #2973

* fixed random to be thread safe #2973

* fixed random to be thread safe #2973

* fixed spacing in pom.xml #2973

---------

Co-authored-by: Ilkka Seppälä <iluwatar@users.noreply.github.com>
This commit is contained in:
Soham Kamble
2026-02-22 20:15:11 +02:00
committed by GitHub
co-authored by Ilkka Seppälä
parent a3fcc63167
commit 265e3d0bda
27 changed files with 1304 additions and 0 deletions
+1
View File
@@ -248,6 +248,7 @@
<module>visitor</module>
<module>backpressure</module>
<module>actor-model</module>
<module>rate-limiting-pattern</module>
</modules>
<repositories>
<repository>
+328
View File
@@ -0,0 +1,328 @@
---
title: "Rate Limiting Pattern in Java: Controlling System Overload Gracefully"
shortTitle: Rate Limiting
description: "Explore multiple rate limiting strategies in Java—Token Bucket, Fixed Window, and Adaptive Limiting. Learn with diagrams, programmatic examples, and real-world simulation."
category: Behavioral
language: en
tag:
- Resilience
- System Overload Protection
- API Throttling
- Concurrency
- Cloud Patterns
---
## Also known as
- Throttling
- Request Limiting
- API Rate Limiting
---
## Intent of Rate Limiting Design Pattern
To regulate the number of requests sent to a service in a specific time window, avoiding resource exhaustion and ensuring system stability. This is especially useful in distributed and cloud-native architectures.
---
## Detailed Explanation of Rate Limiting with Real-World Examples
### Real-world example
Imagine you're entering a concert hall that only allows 50 people per minute. If too many fans arrive at once, the gate staff slows down entry, allowing only a few at a time. This prevents overcrowding and ensures safety. Similarly, the rate limiter controls how many requests are processed to avoid overloading a server.
### In plain words
Regulate the number of requests a system handles within a time frame to protect availability and performance.
### AWS says
> "API Gateway limits the steady-state rate and burst rate of requests that it allows for each method in your REST APIs. When request rates exceed these limits, API Gateway begins to throttle requests."
— [API Gateway quotas and important notes - AWS Documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-request-throttling.html)
---
## Architecture Diagram
![UML Class Diagram](etc/UMLClassDiagram.png)
This UML shows the key components:
- `RateLimiter` interface
- `TokenBucketRateLimiter`, `FixedWindowRateLimiter`, `AdaptiveRateLimiter`
- Supporting exception classes
- `FindCustomerRequest` as a rate-limited operation
---
## Flowcharts
### 1. Token Bucket Strategy
![Token Bucket Rate Limiter](etc/TokenBucketRateLimiter.png)
### 2. Fixed Window Strategy
![Fixed Window Rate Limiter](etc/FixedWindowRateLimiter.png)
### 3. Adaptive Rate Limiting Strategy
![Adaptive Rate Limiter](etc/AdaptiveRateLimiter.png)
---
### Programmatic Example of Rate Limiter Pattern in Java
The **Rate Limiter** design pattern helps protect systems from overload by restricting the number of operations that can be performed in a given time window. It is especially useful when accessing shared resources, APIs, or services that are sensitive to spikes in traffic.
This implementation demonstrates three strategies for rate limiting:
- **Token Bucket Rate Limiter**
- **Fixed Window Rate Limiter**
- **Adaptive Rate Limiter**
Lets walk through the key components.
---
#### 1. Token Bucket Rate Limiter
The token bucket allows short bursts followed by a steady rate. Tokens are added periodically and requests are only allowed if a token is available.
```java
public class TokenBucketRateLimiter implements RateLimiter {
private final int capacity;
private final int refillRate;
private final ConcurrentHashMap<String, TokenBucket> buckets = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public TokenBucketRateLimiter(int capacity, int refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
scheduler.scheduleAtFixedRate(this::refillBuckets, 1, 1, TimeUnit.SECONDS);
}
@Override
public void check(String serviceName, String operationName) throws RateLimitException {
String key = serviceName + ":" + operationName;
TokenBucket bucket = buckets.computeIfAbsent(key, k -> new TokenBucket(capacity));
if (!bucket.tryConsume()) {
throw new ThrottlingException(serviceName, operationName, 1000);
}
}
private void refillBuckets() {
buckets.forEach((k, b) -> b.refill(refillRate));
}
private static class TokenBucket {
private final int capacity;
private final AtomicInteger tokens;
TokenBucket(int capacity) {
this.capacity = capacity;
this.tokens = new AtomicInteger(capacity);
}
boolean tryConsume() {
while (true) {
int current = tokens.get();
if (current <= 0) return false;
if (tokens.compareAndSet(current, current - 1)) return true;
}
}
void refill(int amount) {
tokens.getAndUpdate(current -> Math.min(current + amount, capacity));
}
}
}
```
---
#### 2. Fixed Window Rate Limiter
This strategy uses a simple counter within a fixed time window.
```java
public class FixedWindowRateLimiter implements RateLimiter {
private final int limit;
private final long windowMillis;
private final ConcurrentHashMap<String, WindowCounter> counters = new ConcurrentHashMap<>();
public FixedWindowRateLimiter(int limit, long windowSeconds) {
this.limit = limit;
this.windowMillis = TimeUnit.SECONDS.toMillis(windowSeconds);
}
@Override
public synchronized void check(String serviceName, String operationName) throws RateLimitException {
String key = serviceName + ":" + operationName;
WindowCounter counter = counters.computeIfAbsent(key, k -> new WindowCounter());
if (!counter.tryIncrement()) {
throw new RateLimitException("Rate limit exceeded for " + key, windowMillis);
}
}
private class WindowCounter {
private AtomicInteger count = new AtomicInteger(0);
private volatile long windowStart = System.currentTimeMillis();
synchronized boolean tryIncrement() {
long now = System.currentTimeMillis();
if (now - windowStart > windowMillis) {
count.set(0);
windowStart = now;
}
return count.incrementAndGet() <= limit;
}
}
}
```
---
#### 3. Adaptive Rate Limiter
This version adjusts the rate based on system health, reducing the rate when throttling occurs and recovering periodically.
```java
public class AdaptiveRateLimiter implements RateLimiter {
private final int initialLimit;
private final int maxLimit;
private final AtomicInteger currentLimit;
private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
private final ScheduledExecutorService healthChecker = Executors.newScheduledThreadPool(1);
public AdaptiveRateLimiter(int initialLimit, int maxLimit) {
this.initialLimit = initialLimit;
this.maxLimit = maxLimit;
this.currentLimit = new AtomicInteger(initialLimit);
healthChecker.scheduleAtFixedRate(this::adjustLimits, 10, 10, TimeUnit.SECONDS);
}
@Override
public void check(String serviceName, String operationName) throws RateLimitException {
String key = serviceName + ":" + operationName;
int current = currentLimit.get();
RateLimiter limiter = limiters.computeIfAbsent(key, k -> new TokenBucketRateLimiter(current, current));
try {
limiter.check(serviceName, operationName);
} catch (RateLimitException e) {
currentLimit.updateAndGet(curr -> Math.max(initialLimit, curr / 2));
throw e;
}
}
private void adjustLimits() {
currentLimit.updateAndGet(curr -> Math.min(maxLimit, curr + (initialLimit / 2)));
}
}
```
---
#### 4. Simulated Demo Using All Limiters
```java
public final class App {
public static void main(String[] args) {
TokenBucketRateLimiter tb = new TokenBucketRateLimiter(2, 1);
FixedWindowRateLimiter fw = new FixedWindowRateLimiter(3, 1);
AdaptiveRateLimiter ar = new AdaptiveRateLimiter(2, 6);
ExecutorService executor = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 3; i++) {
executor.submit(createClientTask(i, tb, fw, ar));
}
}
private static Runnable createClientTask(int clientId, RateLimiter tb, RateLimiter fw, RateLimiter ar) {
return () -> {
String[] services = {"s3", "dynamodb", "lambda"};
String[] operations = {"GetObject", "PutObject", "Query", "Scan", "PutItem", "Invoke", "ListFunctions"};
Random random = new Random();
while (true) {
String service = services[random.nextInt(services.length)];
String operation = operations[random.nextInt(operations.length)];
try {
switch (service) {
case "s3" -> tb.check(service, operation);
case "dynamodb" -> fw.check(service, operation);
case "lambda" -> ar.check(service, operation);
}
System.out.printf("Client %d: %s.%s - ALLOWED%n", clientId, service, operation);
} catch (RateLimitException e) {
System.out.printf("Client %d: %s.%s - THROTTLED%n", clientId, service, operation);
}
try {
Thread.sleep(30 + random.nextInt(50));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
}
}
```
---
This example highlights how the Rate Limiter pattern supports various throttling techniques and how they respond under simulated traffic pressure, making it invaluable for building scalable, resilient systems.
## When to Use Rate Limiting
- APIs receiving unpredictable traffic
- Shared cloud resources (e.g., DB, compute)
- Services requiring fair client usage
- Preventing DoS or abuse
---
## Real-World Applications
- **AWS API Gateway**
- **Google Cloud Functions**
- **Netflix Zuul API Gateway**
- **Stripe API Throttling**
---
## Benefits and Trade-offs
### Benefits
- Protects backend from overload
- Fair distribution of resources
- Better user experience under load
### Trade-offs
- May delay valid requests
- Requires tuning of limits
- Could create bottlenecks if misused
---
## Related Java Design Patterns
- [Circuit Breaker](https://java-design-patterns.com/patterns/circuit-breaker/)
- [Retry](https://java-design-patterns.com/patterns/retry/)
- [Throttling Queue](https://java-design-patterns.com/patterns/throttling/)
---
## References and Credits
- [Microsoft Cloud Design Patterns](https://learn.microsoft.com/en-us/azure/architecture/patterns/throttling)
- [AWS API Gateway Throttling](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-request-throttling.html)
- *Designing Data-Intensive Applications* by Martin Kleppmann
- [Resilience4j](https://resilience4j.readme.io/)
- Java Design Patterns Project: [java-design-patterns](https://github.com/iluwatar/java-design-patterns)
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+89
View File
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.iluwatar</groupId>
<artifactId>java-design-patterns</artifactId>
<version>1.26.0-SNAPSHOT</version>
</parent>
<artifactId>rate-limiter</artifactId>
<properties>
<maven.compiler.source>22</maven.compiler.source>
<maven.compiler.target>22</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.jupiter.version>5.11.1</junit.jupiter.version>
<junit.platform.version>1.11.1</junit.platform.version>
</properties>
<dependencies>
<!-- JUnit 5 API and Engine -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.11</version>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.44.2</version>
<executions>
<execution>
<goals>
<goal>check</goal> <!-- Fails the build if formatting is off -->
<goal>apply</goal> <!-- Automatically formats code -->
</goals>
</execution>
</executions>
<configuration>
<java>
<googleJavaFormat />
</java>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<useModulePath>false</useModulePath> <!-- for Java 17+ compatibility -->
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,50 @@
package com.iluwatar.rate.limiting.pattern;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/** Adaptive rate limiter that adjusts limits based on system health. */
public class AdaptiveRateLimiter implements RateLimiter {
private final int initialLimit;
private final int maxLimit;
private final AtomicInteger currentLimit;
private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
private final ScheduledExecutorService healthChecker = Executors.newScheduledThreadPool(1);
public AdaptiveRateLimiter(int initialLimit, int maxLimit) {
this.initialLimit = initialLimit;
this.maxLimit = maxLimit;
this.currentLimit = new AtomicInteger(initialLimit);
// Periodically increase limit to recover if system appears healthy
healthChecker.scheduleAtFixedRate(this::adjustLimits, 10, 10, TimeUnit.SECONDS);
}
@Override
public void check(String serviceName, String operationName) throws RateLimitException {
String key = serviceName + ":" + operationName;
int current = currentLimit.get();
// Reuse or create TokenBucket for this key using currentLimit
RateLimiter limiter =
limiters.computeIfAbsent(key, k -> new TokenBucketRateLimiter(current, current));
try {
limiter.check(serviceName, operationName);
System.out.printf(
"[Adaptive] Allowed %s.%s - CurrentLimit: %d%n", serviceName, operationName, current);
} catch (RateLimitException e) {
// On throttling, reduce system limit to reduce load
currentLimit.updateAndGet(curr -> Math.max(initialLimit, curr / 2));
System.out.printf(
"[Adaptive] Throttled %s.%s - Decreasing limit to %d%n",
serviceName, operationName, currentLimit.get());
throw e;
}
}
// Periodic recovery mechanism to raise limits when the system is under control
private void adjustLimits() {
int updated = currentLimit.updateAndGet(curr -> Math.min(maxLimit, curr + (initialLimit / 2)));
System.out.printf("[Adaptive] Health check passed - Increasing limit to %d%n", updated);
}
}
@@ -0,0 +1,178 @@
package com.iluwatar.rate.limiting.pattern;
import java.security.SecureRandom;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The <em>Rate Limiter</em> pattern is a key defensive strategy used to prevent system overload and
* ensure fair usage of shared services. This demo showcases how different rate limiting techniques
* can regulate traffic in distributed systems.
*
* <p>Specifically, this simulation implements three rate limiter strategies:
*
* <ul>
* <li><b>Token Bucket</b> Allows short bursts followed by steady request rates.
* <li><b>Fixed Window</b> Enforces a strict limit per discrete time window (e.g., 3
* requests/sec).
* <li><b>Adaptive</b> Dynamically scales limits based on system health, simulating elastic
* backoff.
* </ul>
*
* <p>Each simulated service (e.g., S3, DynamoDB, Lambda) is governed by one of these limiters.
* Multiple concurrent client threads issue randomized requests to these services over a fixed
* duration. Each request is either:
*
* <ul>
* <li><b>ALLOWED</b> Permitted under the current rate limit
* <li><b>THROTTLED</b> Rejected due to quota exhaustion
* <li><b>FAILED</b> Dropped due to transient service failure
* </ul>
*
* <p>Statistics are printed every few seconds, and the simulation exits gracefully after a fixed
* runtime, offering a clear view into how each limiter behaves under pressure.
*
* <p><b>Relation to AWS API Gateway:</b><br>
* This implementation mirrors the throttling behavior described in the <a
* href="https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-request-throttling.html">
* AWS API Gateway Request Throttling documentation</a>, where limits are applied per second and
* over longer durations (burst and rate limits). The <code>TokenBucketRateLimiter</code> mimics
* burst capacity, the <code>FixedWindowRateLimiter</code> models steady rate enforcement, and the
* <code>AdaptiveRateLimiter</code> reflects elasticity in real-world systems like AWS Lambda under
* variable load.
*/
public final class App {
private static final Logger LOGGER = LoggerFactory.getLogger(App.class);
private static final int RUN_DURATION_SECONDS = 10;
private static final int SHUTDOWN_TIMEOUT_SECONDS = 5;
static final AtomicInteger successfulRequests = new AtomicInteger();
static final AtomicInteger throttledRequests = new AtomicInteger();
static final AtomicInteger failedRequests = new AtomicInteger();
static final AtomicBoolean running = new AtomicBoolean(true);
private static final String DIVIDER_LINE = "====================================";
public static void main(String[] args) {
LOGGER.info("Starting Rate Limiter Demo");
LOGGER.info(DIVIDER_LINE);
ExecutorService executor = Executors.newFixedThreadPool(3);
ScheduledExecutorService statsPrinter = Executors.newSingleThreadScheduledExecutor();
try {
TokenBucketRateLimiter tb = new TokenBucketRateLimiter(2, 1);
FixedWindowRateLimiter fw = new FixedWindowRateLimiter(3, 1);
AdaptiveRateLimiter ar = new AdaptiveRateLimiter(2, 6);
statsPrinter.scheduleAtFixedRate(App::printStats, 2, 2, TimeUnit.SECONDS);
for (int i = 1; i <= 3; i++) {
executor.submit(createClientTask(i, tb, fw, ar));
}
Thread.sleep(RUN_DURATION_SECONDS * 1000L);
LOGGER.info("Shutting down the demo...");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
running.set(false);
shutdownExecutor(executor, "mainExecutor");
shutdownExecutor(statsPrinter, "statsPrinter");
printFinalStats();
LOGGER.info("Demo completed.");
}
}
private static void shutdownExecutor(ExecutorService service, String name) {
service.shutdown();
try {
if (!service.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
service.shutdownNow();
LOGGER.warn("Forced shutdown of {}", name);
}
} catch (InterruptedException e) {
service.shutdownNow();
Thread.currentThread().interrupt();
}
}
static Runnable createClientTask(
int clientId, RateLimiter s3Limiter, RateLimiter dynamoDbLimiter, RateLimiter lambdaLimiter) {
return () -> {
String[] services = {"s3", "dynamodb", "lambda"};
String[] operations = {
"GetObject", "PutObject", "Query", "Scan", "PutItem", "Invoke", "ListFunctions"
};
SecureRandom random = new SecureRandom(); // Safe & compliant for SonarCloud
while (running.get() && !Thread.currentThread().isInterrupted()) {
try {
String service = services[random.nextInt(services.length)];
String operation = operations[random.nextInt(operations.length)];
switch (service) {
case "s3" -> makeRequest(clientId, s3Limiter, service, operation);
case "dynamodb" -> makeRequest(clientId, dynamoDbLimiter, service, operation);
case "lambda" -> makeRequest(clientId, lambdaLimiter, service, operation);
default -> LOGGER.warn("Unknown service: {}", service);
}
Thread.sleep(30L + random.nextInt(50));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
}
static void makeRequest(int clientId, RateLimiter limiter, String service, String operation) {
try {
limiter.check(service, operation);
successfulRequests.incrementAndGet();
LOGGER.info("Client {}: {}.{} - ALLOWED", clientId, service, operation);
} catch (ThrottlingException e) {
throttledRequests.incrementAndGet();
LOGGER.warn(
"Client {}: {}.{} - THROTTLED (Retry in {}ms)",
clientId,
service,
operation,
e.getRetryAfterMillis());
} catch (ServiceUnavailableException e) {
failedRequests.incrementAndGet();
LOGGER.warn("Client {}: {}.{} - SERVICE UNAVAILABLE", clientId, service, operation);
} catch (Exception e) {
failedRequests.incrementAndGet();
LOGGER.error("Client {}: {}.{} - ERROR: {}", clientId, service, operation, e.getMessage());
}
}
static void printStats() {
if (!running.get()) return;
LOGGER.info("=== Current Statistics ===");
LOGGER.info("Successful Requests: {}", successfulRequests.get());
LOGGER.info("Throttled Requests : {}", throttledRequests.get());
LOGGER.info("Failed Requests : {}", failedRequests.get());
LOGGER.info(DIVIDER_LINE);
}
static void printFinalStats() {
LOGGER.info("Final Statistics");
LOGGER.info(DIVIDER_LINE);
LOGGER.info("Successful Requests: {}", successfulRequests.get());
LOGGER.info("Throttled Requests : {}", throttledRequests.get());
LOGGER.info("Failed Requests : {}", failedRequests.get());
LOGGER.info(DIVIDER_LINE);
}
static void resetCountersForTesting() {
successfulRequests.set(0);
throttledRequests.set(0);
failedRequests.set(0);
}
}
@@ -0,0 +1,40 @@
package com.iluwatar.rate.limiting.pattern;
/**
* A rate-limited customer lookup operation. This class wraps the rate limiting logic and represents
* an executable business request.
*/
public class FindCustomerRequest implements RateLimitOperation<String> {
private final String customerId;
private final RateLimiter rateLimiter;
public FindCustomerRequest(String customerId, RateLimiter rateLimiter) {
this.customerId = customerId;
this.rateLimiter = rateLimiter;
}
@Override
public String getServiceName() {
return "CustomerService";
}
@Override
public String getOperationName() {
return "FindCustomer";
}
@Override
public String execute() throws RateLimitException {
// Ensure the operation respects the assigned rate limiter
rateLimiter.check(getServiceName(), getOperationName());
// Simulate actual operation
try {
Thread.sleep(50); // Simulate processing time
return "Customer-" + customerId;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceUnavailableException(getServiceName(), 1000);
}
}
}
@@ -0,0 +1,53 @@
package com.iluwatar.rate.limiting.pattern;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Implements a fixed window rate limiter. It allows up to 'limit' number of requests within a time
* window of fixed size.
*/
public class FixedWindowRateLimiter implements RateLimiter {
private final int limit;
private final long windowMillis;
private final ConcurrentHashMap<String, WindowCounter> counters = new ConcurrentHashMap<>();
public FixedWindowRateLimiter(int limit, long windowSeconds) {
this.limit = limit;
this.windowMillis = TimeUnit.SECONDS.toMillis(windowSeconds);
}
@Override
public synchronized void check(String serviceName, String operationName)
throws RateLimitException {
String key = serviceName + ":" + operationName;
WindowCounter counter = counters.computeIfAbsent(key, k -> new WindowCounter());
if (!counter.tryIncrement()) {
System.out.printf(
"[FixedWindow] Throttled %s.%s - Limit %d reached in window%n",
serviceName, operationName, limit);
throw new RateLimitException("Rate limit exceeded for " + key, windowMillis);
} else {
System.out.printf(
"[FixedWindow] Allowed %s.%s - Count within window%n", serviceName, operationName);
}
}
/** Tracks the count of requests within the current window. */
private class WindowCounter {
private AtomicInteger count = new AtomicInteger(0);
private volatile long windowStart = System.currentTimeMillis();
synchronized boolean tryIncrement() {
long now = System.currentTimeMillis();
// Reset window if expired
if (now - windowStart > windowMillis) {
count.set(0);
windowStart = now;
}
// Enforce the request limit within window
return count.incrementAndGet() <= limit;
}
}
}
@@ -0,0 +1,15 @@
package com.iluwatar.rate.limiting.pattern;
/** Base exception for rate limiting errors. */
public class RateLimitException extends Exception {
private final long retryAfterMillis;
public RateLimitException(String message, long retryAfterMillis) {
super(message);
this.retryAfterMillis = retryAfterMillis;
}
public long getRetryAfterMillis() {
return retryAfterMillis;
}
}
@@ -0,0 +1,10 @@
package com.iluwatar.rate.limiting.pattern;
/** Represents a business operation that needs rate limiting. Supports type-safe return values. */
public interface RateLimitOperation<T> {
String getServiceName();
String getOperationName();
T execute() throws RateLimitException;
}
@@ -0,0 +1,13 @@
package com.iluwatar.rate.limiting.pattern;
/** Base interface for all rate limiter strategies. */
public interface RateLimiter {
/**
* Checks if a request is allowed under current rate limits
*
* @param serviceName Service being called (e.g., "dynamodb")
* @param operationName Operation being performed (e.g., "Query")
* @throws RateLimitException if request exceeds limits
*/
void check(String serviceName, String operationName) throws RateLimitException;
}
@@ -0,0 +1,15 @@
package com.iluwatar.rate.limiting.pattern;
/** Exception for when a service is temporarily unavailable. */
public class ServiceUnavailableException extends RateLimitException {
private final String serviceName;
public ServiceUnavailableException(String serviceName, long retryAfterMillis) {
super("Service temporarily unavailable: " + serviceName, retryAfterMillis);
this.serviceName = serviceName;
}
public String getServiceName() {
return serviceName;
}
}
@@ -0,0 +1,21 @@
package com.iluwatar.rate.limiting.pattern;
/** Exception thrown when AWS-style throttling occurs. */
public class ThrottlingException extends RateLimitException {
private final String serviceName;
private final String errorCode;
public ThrottlingException(String serviceName, String operationName, long retryAfterMillis) {
super("AWS throttling error for " + serviceName + "/" + operationName, retryAfterMillis);
this.serviceName = serviceName;
this.errorCode = "ThrottlingException";
}
public String getServiceName() {
return serviceName;
}
public String getErrorCode() {
return errorCode;
}
}
@@ -0,0 +1,64 @@
package com.iluwatar.rate.limiting.pattern;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Token Bucket rate limiter implementation. Allows requests to proceed as long as there are tokens
* available in the bucket. Tokens are added at a fixed interval up to a defined capacity.
*/
public class TokenBucketRateLimiter implements RateLimiter {
private final int capacity;
private final int refillRate;
private final ConcurrentHashMap<String, TokenBucket> buckets = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public TokenBucketRateLimiter(int capacity, int refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
// Refill tokens in all buckets every second
scheduler.scheduleAtFixedRate(this::refillBuckets, 1, 1, TimeUnit.SECONDS);
}
@Override
public void check(String serviceName, String operationName) throws RateLimitException {
String key = serviceName + ":" + operationName;
TokenBucket bucket = buckets.computeIfAbsent(key, k -> new TokenBucket(capacity));
if (!bucket.tryConsume()) {
System.out.printf(
"[TokenBucket] Throttled %s.%s - No tokens available%n", serviceName, operationName);
throw new ThrottlingException(serviceName, operationName, 1000);
} else {
System.out.printf(
"[TokenBucket] Allowed %s.%s - Tokens remaining%n", serviceName, operationName);
}
}
private void refillBuckets() {
buckets.forEach((k, b) -> b.refill(refillRate));
}
/** Inner class that represents the bucket holding tokens for each service-operation. */
private static class TokenBucket {
private final int capacity;
private final AtomicInteger tokens;
TokenBucket(int capacity) {
this.capacity = capacity;
this.tokens = new AtomicInteger(capacity);
}
boolean tryConsume() {
while (true) {
int current = tokens.get();
if (current <= 0) return false;
if (tokens.compareAndSet(current, current - 1)) return true;
}
}
void refill(int amount) {
tokens.getAndUpdate(current -> Math.min(current + amount, capacity));
}
}
}
@@ -0,0 +1,56 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class AdaptiveRateLimiterTest {
@Test
void shouldDecreaseLimitWhenThrottled() throws Exception {
AdaptiveRateLimiter limiter = new AdaptiveRateLimiter(10, 20);
// Exceed initial limit
for (int i = 0; i < 11; i++) {
try {
limiter.check("test", "op");
} catch (RateLimitException e) {
// Expected after 10 requests
}
}
// Verify limit was reduced
assertThrows(
RateLimitException.class,
() -> {
for (int i = 0; i < 6; i++) { // New limit should be 5 (10/2)
limiter.check("test", "op");
}
});
}
@Test
void shouldGraduallyIncreaseLimitWhenHealthy() throws Exception {
AdaptiveRateLimiter limiter =
new AdaptiveRateLimiter(4, 10); // Start from 4 expect 2 expect increase to 4
// Force throttling to reduce limit
for (int i = 0; i < 5; i++) {
try {
limiter.check("test", "op");
} catch (RateLimitException e) {
// Expected to throttle and reduce limit
}
}
// Wait for health check to increase limit
Thread.sleep(11000); // Wait slightly more than 10 seconds
// Allow up to 4 requests again (limit should've increased to 4)
for (int i = 0; i < 4; i++) {
limiter.check("test", "op");
}
// 5th should throw exception again
assertThrows(RateLimitException.class, () -> limiter.check("test", "op"));
}
}
@@ -0,0 +1,59 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link App}. */
class AppTest {
private RateLimiter mockLimiter;
@BeforeEach
void setUp() {
mockLimiter = mock(RateLimiter.class);
AppTestUtils.resetCounters(); // Ensures counters are clean before every test
}
@Test
void shouldAllowRequest() {
AppTestUtils.invokeMakeRequest(1, mockLimiter, "s3", "GetObject");
assertEquals(1, AppTestUtils.getSuccessfulRequests().get(), "Successful count should be 1");
assertEquals(0, AppTestUtils.getThrottledRequests().get(), "Throttled count should be 0");
assertEquals(0, AppTestUtils.getFailedRequests().get(), "Failed count should be 0");
}
@Test
void shouldHandleThrottlingException() throws Exception {
doThrow(new ThrottlingException("s3", "PutObject", 1000)).when(mockLimiter).check(any(), any());
AppTestUtils.invokeMakeRequest(2, mockLimiter, "s3", "PutObject");
assertEquals(0, AppTestUtils.getSuccessfulRequests().get());
assertEquals(1, AppTestUtils.getThrottledRequests().get());
assertEquals(0, AppTestUtils.getFailedRequests().get());
}
@Test
void shouldHandleServiceUnavailableException() throws Exception {
doThrow(new ServiceUnavailableException("lambda", 500)).when(mockLimiter).check(any(), any());
AppTestUtils.invokeMakeRequest(3, mockLimiter, "lambda", "Invoke");
assertEquals(0, AppTestUtils.getSuccessfulRequests().get());
assertEquals(0, AppTestUtils.getThrottledRequests().get());
assertEquals(1, AppTestUtils.getFailedRequests().get());
}
@Test
void shouldHandleGenericException() throws Exception {
doThrow(new RuntimeException("Unexpected")).when(mockLimiter).check(any(), any());
AppTestUtils.invokeMakeRequest(4, mockLimiter, "dynamodb", "Query");
assertEquals(0, AppTestUtils.getSuccessfulRequests().get());
assertEquals(0, AppTestUtils.getThrottledRequests().get());
assertEquals(1, AppTestUtils.getFailedRequests().get());
}
@Test
void shouldRunMainMethodWithoutException() {
assertDoesNotThrow(() -> App.main(new String[] {}));
}
}
@@ -0,0 +1,27 @@
package com.iluwatar.rate.limiting.pattern;
import java.util.concurrent.atomic.AtomicInteger;
public class AppTestUtils {
public static void invokeMakeRequest(
int clientId, RateLimiter limiter, String service, String operation) {
App.makeRequest(clientId, limiter, service, operation);
}
public static void resetCounters() {
App.resetCountersForTesting();
}
public static AtomicInteger getSuccessfulRequests() {
return App.successfulRequests;
}
public static AtomicInteger getThrottledRequests() {
return App.throttledRequests;
}
public static AtomicInteger getFailedRequests() {
return App.failedRequests;
}
}
@@ -0,0 +1,69 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class ConcurrencyTests {
@Test
void tokenBucketShouldHandleConcurrentRequests() throws Exception {
int threadCount = 10;
int requestLimit = 5;
RateLimiter limiter = new TokenBucketRateLimiter(requestLimit, requestLimit);
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failureCount = new AtomicInteger();
for (int i = 0; i < threadCount; i++) {
executor.submit(
() -> {
try {
limiter.check("test", "op");
successCount.incrementAndGet();
} catch (RateLimitException e) {
failureCount.incrementAndGet();
}
latch.countDown();
});
}
latch.await();
assertEquals(requestLimit, successCount.get());
assertEquals(threadCount - requestLimit, failureCount.get());
}
@Test
void adaptiveLimiterShouldAdjustUnderLoad() throws Exception {
AdaptiveRateLimiter limiter = new AdaptiveRateLimiter(10, 20);
ExecutorService executor = Executors.newFixedThreadPool(20);
// Flood with requests to trigger throttling
for (int i = 0; i < 30; i++) {
executor.submit(
() -> {
try {
limiter.check("test", "op");
} catch (RateLimitException ignored) {
}
});
}
Thread.sleep(15000); // Wait for adjustment
// Verify new limit is in effect
int allowed = 0;
for (int i = 0; i < 20; i++) {
try {
limiter.check("test", "op");
allowed++;
} catch (RateLimitException ignored) {
}
}
assertTrue(allowed > 5 && allowed < 15); // Should be between initial and max
}
}
@@ -0,0 +1,28 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class ExceptionTests {
@Test
void rateLimitExceptionShouldContainRetryInfo() {
RateLimitException exception = new RateLimitException("Test", 1000);
assertEquals(1000, exception.getRetryAfterMillis());
assertEquals("Test", exception.getMessage());
}
@Test
void throttlingExceptionShouldContainServiceInfo() {
ThrottlingException exception = new ThrottlingException("dynamodb", "Query", 500);
assertEquals("dynamodb", exception.getServiceName());
assertEquals("ThrottlingException", exception.getErrorCode());
}
@Test
void serviceUnavailableExceptionShouldContainRetryInfo() {
ServiceUnavailableException exception = new ServiceUnavailableException("s3", 2000);
assertEquals("s3", exception.getServiceName());
assertEquals(2000, exception.getRetryAfterMillis());
}
}
@@ -0,0 +1,62 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class FindCustomerRequestTest implements RateLimitOperationTest<String> {
@Override
public RateLimitOperation<String> createOperation(RateLimiter limiter) {
return new FindCustomerRequest("123", limiter);
}
@Test
void shouldExecuteWhenUnderRateLimit() throws Exception {
RateLimiter limiter = new TokenBucketRateLimiter(10, 10);
RateLimitOperation<String> request = createOperation(limiter);
String result = request.execute();
assertEquals("Customer-123", result);
}
@Test
void shouldThrowWhenRateLimitExceeded() {
RateLimiter limiter = new TokenBucketRateLimiter(0, 0); // Always throttled
RateLimitOperation<String> request = createOperation(limiter);
assertThrows(RateLimitException.class, request::execute);
}
@Test
void shouldReturnCorrectServiceAndOperationNames() {
RateLimiter limiter = new TokenBucketRateLimiter(10, 10);
FindCustomerRequest request = new FindCustomerRequest("123", limiter);
assertEquals("CustomerService", request.getServiceName());
assertEquals("FindCustomer", request.getOperationName());
}
// Reuse helper logic from the interface for coverage
@Test
void shouldExecuteUsingDefaultHelper() throws Exception {
RateLimiter limiter = new TokenBucketRateLimiter(5, 5);
shouldExecuteWhenUnderLimit(createOperation(limiter));
}
@Test
void shouldThrowServiceUnavailableOnInterruptedException() {
RateLimiter noOpLimiter = (service, operation) -> {}; // no throttling
FindCustomerRequest request =
new FindCustomerRequest("999", noOpLimiter) {
@Override
public String execute() throws RateLimitException {
Thread.currentThread().interrupt(); // Simulate thread interruption
return super.execute(); // Should throw ServiceUnavailableException
}
};
assertThrows(ServiceUnavailableException.class, request::execute);
}
}
@@ -0,0 +1,40 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class FixedWindowRateLimiterTest extends RateLimiterTest {
@Override
protected RateLimiter createRateLimiter(int limit, long windowMillis) {
return new FixedWindowRateLimiter(limit, windowMillis / 1000);
}
@Test
void shouldResetCounterAfterWindow() throws Exception {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(1, 1); // 1 request per 1 second window
// First request should pass
limiter.check("test", "op");
// Second request in same window should be throttled
assertThrows(RateLimitException.class, () -> limiter.check("test", "op"));
// Wait a bit more than 1 second to ensure window resets
TimeUnit.MILLISECONDS.sleep(1100);
// After window reset, this should pass again
limiter.check("test", "op");
}
@Test
void shouldNotAllowMoreThanLimitInWindow() throws Exception {
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(3, 1);
for (int i = 0; i < 3; i++) {
limiter.check("test", "op");
}
assertThrows(RateLimitException.class, () -> limiter.check("test", "op"));
}
}
@@ -0,0 +1,22 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
interface RateLimitOperationTest<T> {
RateLimitOperation<T> createOperation(RateLimiter limiter);
@Test
default void shouldThrowWhenRateLimited() {
RateLimiter limiter = new TokenBucketRateLimiter(0, 0); // Always throttled
RateLimitOperation<T> operation = createOperation(limiter);
assertThrows(RateLimitException.class, operation::execute);
}
// No @Test here, just a helper method
default void shouldExecuteWhenUnderLimit(RateLimitOperation<T> operation) throws Exception {
assertNotNull(operation.execute());
}
}
@@ -0,0 +1,25 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
public abstract class RateLimiterTest {
protected abstract RateLimiter createRateLimiter(int limit, long windowMillis);
@Test
void shouldAllowRequestsWithinLimit() throws Exception {
RateLimiter limiter = createRateLimiter(5, 1000);
for (int i = 0; i < 5; i++) {
limiter.check("test", "op");
}
}
@Test
void shouldThrowWhenLimitExceeded() throws Exception {
RateLimiter limiter = createRateLimiter(2, 1000);
limiter.check("test", "op");
limiter.check("test", "op");
assertThrows(RateLimitException.class, () -> limiter.check("test", "op"));
}
}
@@ -0,0 +1,39 @@
package com.iluwatar.rate.limiting.pattern;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class TokenBucketRateLimiterTest extends RateLimiterTest {
@Override
protected RateLimiter createRateLimiter(int limit, long windowMillis) {
return new TokenBucketRateLimiter(limit, (int) (limit * 1000 / windowMillis));
}
@Test
void shouldAllowBurstRequests() throws Exception {
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(10, 5);
for (int i = 0; i < 10; i++) {
limiter.check("test", "op");
}
}
@Test
void shouldRefillTokensAfterTime() throws Exception {
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(1, 1);
limiter.check("test", "op");
assertThrows(RateLimitException.class, () -> limiter.check("test", "op"));
TimeUnit.SECONDS.sleep(1);
limiter.check("test", "op");
}
@Test
void shouldHandleMultipleServicesSeparately() throws Exception {
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(1, 1);
limiter.check("service1", "op");
limiter.check("service2", "op");
assertThrows(RateLimitException.class, () -> limiter.check("service1", "op"));
}
}