Skip to content

Rate Limiting Pattern #2973 #3291

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dd0fc2b
need to fix one test case shouldGraduallyIncreaseLimitWhenHealthy fai…
skamble2 May 31, 2025
ed01b7b
Added Class Diagram and Flow Diagrams for Adaptive, Fixed Window and …
skamble2 Jun 1, 2025
331beb9
Updated README.md. All test case passed. Updated with Google Java Gui…
skamble2 Jun 1, 2025
1f3ad40
Updated parent pom #2973
skamble2 Jun 1, 2025
3526c05
Updated parent pom #2973
skamble2 Jun 1, 2025
26cb1b2
fixed shouldResetCounterAfterWindow() test #2973
skamble2 Jun 1, 2025
8afefee
formatting fixed #2973
skamble2 Jun 1, 2025
fe253e4
added test coverage for app.java and fixed random to be thread safe …
skamble2 Jun 1, 2025
3a9de27
added test coverage for app.java and fixed random to be thread safe …
skamble2 Jun 1, 2025
5120195
added test coverage for app.java and fixed random to be thread safe …
skamble2 Jun 1, 2025
0889442
added test coverage for app.java and fixed random to be thread safe …
skamble2 Jun 1, 2025
4e886f8
added test coverage for app.java and fixed random to be thread safe …
skamble2 Jun 1, 2025
54d7396
added test coverage for app.java and fixed random to be thread safe …
skamble2 Jun 1, 2025
b6002a5
added test coverage for app.java and fixed random to be thread safe …
skamble2 Jun 1, 2025
dfe6753
fixed random to be thread safe #2973
skamble2 Jun 1, 2025
28e8b4a
fixed random to be thread safe #2973
skamble2 Jun 1, 2025
7fb2cc5
fixed random to be thread safe #2973
skamble2 Jun 12, 2025
9312225
fixed spacing in pom.xml #2973
skamble2 Jun 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -244,8 +244,9 @@
<module>version-number</module>
<module>virtual-proxy</module>
<module>visitor</module>
<module>backpressure</module>
<module>actor-model</module>
<module>backpressure</module>
<module>actor-model</module>
<module>rate-limiting-pattern</module>

</modules>
<repositories>
328 changes: 328 additions & 0 deletions rate-limiting-pattern/README.md
Original file line number Diff line number Diff line change
@@ -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**

Let’s 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)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rate-limiting-pattern/etc/UMLClassDiagram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 89 additions & 0 deletions rate-limiting-pattern/pom.xml
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
@@ -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[] {}));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}