Build a Java Rate Limiter Library
Build a published Maven rate-limiter library with lock-free concurrency algorithms.
Introduction
30 Second Summary
Every time you refresh a sold-out ticket page or hit a "too many requests" error from an API, you're experiencing rate limiting in action. It's the mechanism that keeps services responsive when demand spikes beyond capacity.
In this project, you will build throttle4j, a reusable Java rate-limiting library with three lock-free algorithms, a fluent builder API, and a Spring Boot auto-configuration starter. You'll publish it to GitHub Packages using a GitHub Actions CI/CD pipeline.
What You'll Build
A two-module Maven library (throttle4j-core and throttle4j-spring) that provides pluggable rate-limiting algorithms with lock-free concurrency for high-throughput scenarios.
By the end of this project, you'll have:
- Three production-grade rate-limiting algorithms (Token Bucket, Fixed Window, Sliding Window Log) with lock-free thread safety using AtomicLong and CAS operations.
- A fluent builder API and a Spring Boot auto-configuration starter for YAML-driven rate limiting.
- A GitHub Actions CI/CD workflow that automatically builds, tests, and publishes your library to GitHub Packages on version tag push.
- Secret Mission: Add a metrics/listener API that tracks permit grants, rejections, and wait times for observability integration.
Are there any prerequisites?
This project assumes intermediate-to-advanced Java proficiency and familiarity with Maven basics (pom.xml structure and build lifecycle). You'll also need a GitHub account with repository access.
Set Up the Multi-Module Maven Project
A Maven multi-module project lets you split a library into separate modules that are versioned and published together from a single root. This keeps your core logic decoupled from framework integrations while sharing a common build configuration.
In this step, you will scaffold the throttle4j project root with a parent POM that declares two modules: throttle4j-core and throttle4j-spring. You will also configure the build plugins and verify that everything compiles cleanly.
In this step, get ready to:
- Create the parent POM with module declarations and GitHub Packages distribution.
- Scaffold the directory structure for both modules.
- Create each module's POM and verify the build compiles.
Create the parent POM with module declarations
The parent POM is the control center of a multi-module Maven project. It declares which sub-modules exist, centralizes dependency versions, and configures build plugins that all modules inherit.
- In your IDE, open the cloned throttle4j repository folder.
- Create a file named pom.xml in the root of the throttle4j folder with the following content:
<?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>
<groupId>com.github.dhruv0306</groupId>
<artifactId>throttle4j</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>throttle4j</name>
<description>A Java rate-limiting library with pluggable algorithms and lock-free concurrency</description>
<url>https://github.com/Dhruv0306/throttle4j</url>
<modules>
<module>throttle4j-core</module>
<module>throttle4j-spring</module>
</modules>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.14.4</junit.version>
<spring-boot.version>3.5.14</spring-boot.version>
<gpg.skip>true</gpg.skip>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
</plugins>
</build>
<distributionManagement>
<repository>
<id>github</id>
<name>GitHub Dhruv0306 Apache Maven Packages</name>
<url>https://maven.pkg.github.com/Dhruv0306/throttle4j</url>
</repository>
</distributionManagement>
</project>
What does this POM do?
The <modules> block tells Maven this project contains two sub-modules: throttle4j-core (the algorithms) and throttle4j-spring (the Spring Boot integration). Maven builds them together in dependency order automatically.
The <dependencyManagement> section pins JUnit 5.14.4 so modules inherit the version without repeating it. The maven-compiler-plugin 3.15.0 targets Java 17, and maven-surefire-plugin 3.5.6 runs unit tests.
The <distributionManagement> block points to GitHub Packages so that mvn deploy publishes artifacts to your repository. The <gpg.skip>true</gpg.skip> property disables GPG signing, which would otherwise fail during GitHub Actions since you are using token-based auth instead of GPG keys.
Why does the groupId match my GitHub username?
GitHub Packages requires the groupId to align with the repository owner structure. Using com.github.dhruv0306 matches the owner in the distribution URL. A mismatched groupId results in a 403 Forbidden error on deploy.
- Save pom.xml.
IDE showing XML errors?
Your IDE might flag unresolved modules since the throttle4j-core and throttle4j-spring directories do not exist yet. These warnings disappear once you create the module directories in the next substep.
help me fix XML errors in my parent pom.xml
✔️ Awesome, I've got everything!
Great. Make sure you have saved pom.xml before moving on.
ⓧ I'd like to double check the full code
<?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>
<groupId>com.github.dhruv0306</groupId>
<artifactId>throttle4j</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>throttle4j</name>
<description>A Java rate-limiting library with pluggable algorithms and lock-free concurrency</description>
<url>https://github.com/Dhruv0306/throttle4j</url>
<modules>
<module>throttle4j-core</module>
<module>throttle4j-spring</module>
</modules>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.14.4</junit.version>
<spring-boot.version>3.5.14</spring-boot.version>
<gpg.skip>true</gpg.skip>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
</plugins>
</build>
<distributionManagement>
<repository>
<id>github</id>
<name>GitHub Dhruv0306 Apache Maven Packages</name>
<url>https://maven.pkg.github.com/Dhruv0306/throttle4j</url>
</repository>
</distributionManagement>
</project>
Scaffold the module directory structure
Maven expects each module to follow the standard directory layout with src/main/java for source code and src/test/java for tests.
- Open a terminal in the root of your throttle4j folder.
- Create the directory structure for both modules by running these commands:
mkdir -p throttle4j-core/src/main/java/com/github/dhruv0306/throttle4j/core
mkdir -p throttle4j-core/src/test/java/com/github/dhruv0306/throttle4j/core
mkdir -p throttle4j-spring/src/main/java/com/github/dhruv0306/throttle4j/spring
mkdir -p throttle4j-spring/src/main/resources/META-INF/spring
mkdir -p throttle4j-spring/src/test/java/com/github/dhruv0306/throttle4j/spring
What does this create?
The -p flag creates all intermediate directories. You now have the standard Maven source layout for both throttle4j-core (algorithms) and throttle4j-spring (Spring Boot integration). The META-INF/spring folder inside throttle4j-spring is where Spring Boot's auto-configuration registration file lives.
- Confirm the module directories exist by running:
ls
You should see pom.xml, throttle4j-core, and throttle4j-spring in the output.
Missing directories?
Make sure your terminal is inside the throttle4j root folder (the one containing pom.xml). If you accidentally ran the commands one level up, delete the stray folders and re-run after cd throttle4j.
help me fix my throttle4j directory structure
Create the module POMs and verify the build
Each module needs its own pom.xml that declares the parent relationship and module-specific dependencies. The core module has zero runtime dependencies. The Spring module depends on both the core and the Spring Boot starter.
- Create a file named pom.xml inside the throttle4j-core folder with the following content:
<?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.github.dhruv0306</groupId>
<artifactId>throttle4j</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>throttle4j-core</artifactId>
<packaging>jar</packaging>
<name>throttle4j-core</name>
<description>Core rate-limiting algorithms with lock-free concurrency</description>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
What does this module POM do?
The <parent> block links this module back to the root POM so it inherits all properties, plugins, and dependency versions. Notice that junit-jupiter does not specify a version. It inherits 5.14.4 from the parent's <dependencyManagement>.
The test scope means JUnit is only available during test compilation and execution. It will not be bundled into the published JAR.
- Save throttle4j-core/pom.xml.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on to the Spring module.
ⓧ I'd like to double check the full code
<?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.github.dhruv0306</groupId>
<artifactId>throttle4j</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>throttle4j-core</artifactId>
<packaging>jar</packaging>
<name>throttle4j-core</name>
<description>Core rate-limiting algorithms with lock-free concurrency</description>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
- Now create a file named pom.xml inside the throttle4j-spring folder with the following content:
<?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.github.dhruv0306</groupId>
<artifactId>throttle4j</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>throttle4j-spring</artifactId>
<packaging>jar</packaging>
<name>throttle4j-spring</name>
<description>Spring Boot auto-configuration for throttle4j rate limiting</description>
<dependencies>
<dependency>
<groupId>com.github.dhruv0306</groupId>
<artifactId>throttle4j-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>${spring-boot.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
What does this module POM do?
The Spring module depends on throttle4j-core using ${project.version} so both modules always share the same version number.
The spring-boot-starter dependency uses provided scope. This means the consuming application supplies Spring Boot at runtime. Your library does not bundle the entire framework into its JAR.
The spring-boot-starter-test in test scope gives you @SpringBootTest for integration testing the auto-configuration later.
- Save throttle4j-spring/pom.xml.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before running the build.
ⓧ I'd like to double check the full code
<?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.github.dhruv0306</groupId>
<artifactId>throttle4j</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>throttle4j-spring</artifactId>
<packaging>jar</packaging>
<name>throttle4j-spring</name>
<description>Spring Boot auto-configuration for throttle4j rate limiting</description>
<dependencies>
<dependency>
<groupId>com.github.dhruv0306</groupId>
<artifactId>throttle4j-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>${spring-boot.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
- Verify the entire project compiles by running this command from the throttle4j root folder:
mvn clean verify
You should see BUILD SUCCESS in the terminal output, confirming that Maven resolved both modules and compiled them without errors.
Seeing BUILD FAILURE?
If you see a message about missing child modules, double-check that the throttle4j-core and throttle4j-spring directories are at the same level as the root pom.xml. Each module folder must contain its own pom.xml.
If Maven reports unresolved dependencies, verify that the <parent> block in each module POM has the correct groupId com.github.dhruv0306 and version 1.0.0.
help me debug my Maven multi-module build failure
Your multi-module project skeleton is ready. Next up, you will implement the core rate limiter interface and your first algorithm: the Token Bucket.
Implement the Core Rate Limiter API and Token Bucket Algorithm
Your Maven multi-module skeleton compiles and both modules resolve their dependencies. The scaffolding is ready.
Now you need the actual library API that consumers will import. A rate limiter's job is to answer one question: "can this request proceed right now?" The challenge is answering that question safely when dozens of threads ask simultaneously. In this step you will define the public contract, then implement the first algorithm using lock-free concurrency primitives.
In this step, get ready to:
- Create the RateLimiter interface and Algorithm enum that form the library's public API.
- Implement the Token Bucket algorithm using AtomicLong and CAS loops for lock-free thread safety.
- Build a fluent builder API and prove correctness with JUnit 5 tests including a 16-thread stress test.
Create the RateLimiter interface and Algorithm enum
Every library needs a clean public contract. Consumers of throttle4j will program against a RateLimiter interface without knowing which algorithm runs underneath. This is the Strategy pattern in action.
- Create the package directory structure by running this command from your project root:
mkdir -p throttle4j-core/src/main/java/com/github/dhruv0306/throttle4j/core
- Create a new file called Algorithm.java inside throttle4j-core/src/main/java/com/github/dhruv0306/throttle4j/core/ and add the following code:
package com.github.dhruv0306.throttle4j.core;
public enum Algorithm {
TOKEN_BUCKET,
FIXED_WINDOW,
SLIDING_WINDOW_LOG
}
What does this code do?
This enum defines the three rate-limiting strategies the library will support. Consumers pick one when configuring their limiter. You will implement Token Bucket now, and add the other two in the next step.
- Create a new file called RateLimiter.java in the same package directory and add this code:
package com.github.dhruv0306.throttle4j.core;
public interface RateLimiter {
// Non-blocking: returns true if a permit is available, false otherwise
boolean tryAcquire();
// Blocking: waits until a permit becomes available (virtual-thread friendly)
void acquire();
// Returns the current number of available permits
long getAvailablePermits();
// Entry point to the fluent builder API
static RateLimiterBuilder builder() {
return new RateLimiterBuilder();
}
}
What does this code do?
This interface is the entire public surface of your library. Consumers only interact with these three methods.
- tryAcquire() is non-blocking. It returns immediately with a boolean, perfect for rejecting excess traffic without stalling the calling thread.
- acquire() blocks until a permit is available. It uses LockSupport.parkNanos() internally so it does not pin the carrier thread when running on virtual threads.
- The static builder() factory method gives consumers a fluent configuration entry point.
Why LockSupport.parkNanos() instead of Thread.sleep()?
Thread.sleep() pins the carrier thread when running inside a virtual thread (Project Loom). LockSupport.parkNanos() allows the scheduler to unmount the virtual thread from its carrier, keeping your library efficient on Java 21+ runtimes.
- Save both files.
Implement TokenBucketLimiter with lock-free concurrency
The Token Bucket algorithm imagines a bucket that holds tokens. Each request consumes one token. Tokens refill at a fixed rate over time. If the bucket is empty, the request is rejected.
The tricky part: multiple threads reach into the bucket simultaneously. Traditional locks would serialize access and kill throughput. Instead, you will use AtomicLong with compare-and-set (CAS) loops for lock-free concurrency.
- Create a new file called TokenBucketLimiter.java in the same package directory and add the following code:
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
class TokenBucketLimiter implements RateLimiter {
private final long capacity;
private final long refillTokens;
private final long refillNanos;
private final AtomicLong tokens;
private final AtomicLong lastRefillTime;
TokenBucketLimiter(long capacity, long refillTokens, Duration refillDuration) {
this.capacity = capacity;
this.refillTokens = refillTokens;
this.refillNanos = refillDuration.toNanos();
this.tokens = new AtomicLong(capacity);
this.lastRefillTime = new AtomicLong(System.nanoTime());
}
@Override
public boolean tryAcquire() {
refill();
while (true) {
long current = tokens.get();
if (current <= 0) return false;
if (tokens.compareAndSet(current, current - 1)) return true;
}
}
@Override
public void acquire() {
while (!tryAcquire()) {
LockSupport.parkNanos(refillNanos / refillTokens);
}
}
@Override
public long getAvailablePermits() {
refill();
return tokens.get();
}
private void refill() {
long now = System.nanoTime();
long last = lastRefillTime.get();
long elapsed = now - last;
if (elapsed < refillNanos) {
long tokensToAdd = (elapsed * refillTokens) / refillNanos;
if (tokensToAdd > 0 && lastRefillTime.compareAndSet(last, now)) {
long current = tokens.get();
long newTokens = Math.min(capacity, current + tokensToAdd);
tokens.set(newTokens);
}
} else {
if (lastRefillTime.compareAndSet(last, now)) {
tokens.set(capacity);
}
}
}
}
What does this code do?
This is the heart of the lock-free design. Here is how each piece works:
- tokens (AtomicLong) tracks the current token count. lastRefillTime (AtomicLong) stores the nanosecond timestamp of the last refill.
- tryAcquire() first calls refill() to add any tokens earned since the last check, then enters a CAS loop. The loop reads the current count, checks if tokens are available, and atomically decrements. If another thread changed the value between read and write, the CAS fails and the loop retries.
- acquire() spins on tryAcquire() and parks the thread between attempts. The park duration is one token's worth of refill time.
- refill() calculates how many tokens to add based on elapsed nanoseconds. The CAS on lastRefillTime ensures only one thread wins the refill race.
What is a CAS loop?
CAS stands for Compare-And-Set. The thread says "I expect this value to be X. If it still is, change it to Y." If another thread changed the value first, the operation returns false and you retry. This gives thread safety without locks, which means no thread ever blocks waiting for a monitor.
- Save TokenBucketLimiter.java.
Seeing a compile error about the class visibility?
Make sure the class is declared as package-private (no public keyword). The line should read class TokenBucketLimiter implements RateLimiter. Consumers use the interface, not the implementation directly.
Help me debug my TokenBucketLimiter class
Build the fluent builder API and write tests
A fluent builder lets consumers configure the limiter with chained method calls. This pattern makes the API self-documenting and hard to misuse.
- Create a new file called RateLimiterBuilder.java in the same package directory and add the following code:
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
public class RateLimiterBuilder {
private Algorithm algorithm = Algorithm.TOKEN_BUCKET;
private long capacity = 10;
private long refillTokens = 10;
private Duration refillDuration = Duration.ofSeconds(1);
// Package-private constructor: only accessible via RateLimiter.builder()
RateLimiterBuilder() {}
public RateLimiterBuilder algorithm(Algorithm algorithm) {
this.algorithm = algorithm;
return this;
}
public RateLimiterBuilder capacity(long capacity) {
if (capacity <= 0) throw new IllegalArgumentException("Capacity must be positive");
this.capacity = capacity;
return this;
}
public RateLimiterBuilder refillRate(long tokens, Duration duration) {
if (tokens <= 0) throw new IllegalArgumentException("Refill tokens must be positive");
if (duration.isZero() || duration.isNegative()) throw new IllegalArgumentException("Duration must be positive");
this.refillTokens = tokens;
this.refillDuration = duration;
return this;
}
public RateLimiter build() {
return switch (algorithm) {
case TOKEN_BUCKET -> new TokenBucketLimiter(capacity, refillTokens, refillDuration);
case FIXED_WINDOW -> new FixedWindowLimiter(capacity, refillDuration);
case SLIDING_WINDOW_LOG -> new SlidingWindowLogLimiter(capacity, refillDuration);
};
}
}
What does this code do?
- Each setter method returns this so calls chain together: RateLimiter.builder().algorithm(Algorithm.TOKEN_BUCKET).capacity(100).refillRate(10, Duration.ofSeconds(1)).build().
- The constructor is package-private, so consumers must enter through RateLimiter.builder().
- The build() method uses a switch expression to create the correct implementation based on the chosen algorithm. The FIXED_WINDOW and SLIDING_WINDOW_LOG cases reference classes you will implement in the next step.
The build() method references FixedWindowLimiter and SlidingWindowLogLimiter, which do not exist yet. You need placeholder files so the project compiles.
- Create FixedWindowLimiter.java in the same package directory with this temporary stub:
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
// Temporary stub - full implementation in Step 3
class FixedWindowLimiter implements RateLimiter {
FixedWindowLimiter(long maxRequests, Duration windowDuration) {}
@Override public boolean tryAcquire() { throw new UnsupportedOperationException(); }
@Override public void acquire() { throw new UnsupportedOperationException(); }
@Override public long getAvailablePermits() { throw new UnsupportedOperationException(); }
}
- Create SlidingWindowLogLimiter.java in the same package directory with this temporary stub:
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
// Temporary stub - full implementation in Step 3
class SlidingWindowLogLimiter implements RateLimiter {
SlidingWindowLogLimiter(long maxRequests, Duration windowDuration) {}
@Override public boolean tryAcquire() { throw new UnsupportedOperationException(); }
@Override public void acquire() { throw new UnsupportedOperationException(); }
@Override public long getAvailablePermits() { throw new UnsupportedOperationException(); }
}
These stubs are temporary
You will replace both stub files with full lock-free implementations in the next step. For now they just need to exist so the switch expression compiles.
Now write tests to prove the Token Bucket algorithm works correctly under single-threaded and multi-threaded conditions.
- Create the test directory structure by running this command:
mkdir -p throttle4j-core/src/test/java/com/github/dhruv0306/throttle4j/core
- Create a new file called TokenBucketLimiterTest.java inside throttle4j-core/src/test/java/com/github/dhruv0306/throttle4j/core/ and add the following test code:
package com.github.dhruv0306.throttle4j.core;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class TokenBucketLimiterTest {
@Test
void shouldConsumeTokensUntilEmpty() {
// Create a limiter with capacity 5
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.TOKEN_BUCKET)
.capacity(5)
.refillRate(5, Duration.ofSeconds(1))
.build();
// All 5 permits should succeed
for (int i = 0; i < 5; i++) {
assertTrue(limiter.tryAcquire());
}
// 6th request should be rejected
assertFalse(limiter.tryAcquire());
}
@Test
void shouldRefillTokensOverTime() throws InterruptedException {
// Refills 5 tokens every 100ms
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.TOKEN_BUCKET)
.capacity(5)
.refillRate(5, Duration.ofMillis(100))
.build();
// Drain all tokens
for (int i = 0; i < 5; i++) {
limiter.tryAcquire();
}
assertFalse(limiter.tryAcquire());
// Wait for refill period to elapse
Thread.sleep(150);
// Tokens should have refilled
assertTrue(limiter.tryAcquire());
}
@Test
void shouldHandleConcurrentAccessWithoutOverGranting() throws InterruptedException {
// Capacity 100, slow refill so we can measure grants accurately
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.TOKEN_BUCKET)
.capacity(100)
.refillRate(1, Duration.ofSeconds(10))
.build();
int threadCount = 16;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger acquired = new AtomicInteger(0);
// Each of 16 threads tries to acquire 20 permits (320 total attempts)
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
for (int j = 0; j < 20; j++) {
if (limiter.tryAcquire()) {
acquired.incrementAndGet();
}
}
latch.countDown();
});
}
latch.await();
executor.shutdown();
// No more than 100 permits should have been granted (capacity)
assertTrue(acquired.get() <= 100,
"Over-granted! Got " + acquired.get() + " but capacity is 100");
assertTrue(acquired.get() > 0, "Should have granted at least some permits");
}
}
What do these tests prove?
- shouldConsumeTokensUntilEmpty verifies the basic contract: consume capacity tokens, then the next attempt is rejected.
- shouldRefillTokensOverTime drains the bucket, sleeps past the refill window, and confirms tokens reappear.
- shouldHandleConcurrentAccessWithoutOverGranting launches 16 threads racing to acquire 320 total permits from a bucket of 100. The assertion proves the CAS loop never over-grants.
- Save all files.
- Run the full build from the project root to confirm everything compiles and all tests pass:
mvn clean verify
- You should see BUILD SUCCESS in the terminal output, with 3 tests run and 0 failures.
Seeing compilation errors?
- If you see "cannot find symbol" for FixedWindowLimiter or SlidingWindowLogLimiter, make sure you created both stub files in the correct package directory.
- If you see "constructor not found" errors, check that the stub constructors match the parameters the builder passes: FixedWindowLimiter(long, Duration) and SlidingWindowLogLimiter(long, Duration).
- Make sure all .java files in the core module use the package declaration package com.github.dhruv0306.throttle4j.core; and are saved inside the matching directory path.
Help me fix my throttle4j build errors
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in the throttle4j-core module before moving on.
ⓧ I'd like to double check the full code
Here are the four main source files for this step. Compare them with yours:
package com.github.dhruv0306.throttle4j.core;
public enum Algorithm {
TOKEN_BUCKET,
FIXED_WINDOW,
SLIDING_WINDOW_LOG
}
package com.github.dhruv0306.throttle4j.core;
public interface RateLimiter {
boolean tryAcquire();
void acquire();
long getAvailablePermits();
static RateLimiterBuilder builder() {
return new RateLimiterBuilder();
}
}
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
public class RateLimiterBuilder {
private Algorithm algorithm = Algorithm.TOKEN_BUCKET;
private long capacity = 10;
private long refillTokens = 10;
private Duration refillDuration = Duration.ofSeconds(1);
RateLimiterBuilder() {}
public RateLimiterBuilder algorithm(Algorithm algorithm) {
this.algorithm = algorithm;
return this;
}
public RateLimiterBuilder capacity(long capacity) {
if (capacity <= 0) throw new IllegalArgumentException("Capacity must be positive");
this.capacity = capacity;
return this;
}
public RateLimiterBuilder refillRate(long tokens, Duration duration) {
if (tokens <= 0) throw new IllegalArgumentException("Refill tokens must be positive");
if (duration.isZero() || duration.isNegative()) throw new IllegalArgumentException("Duration must be positive");
this.refillTokens = tokens;
this.refillDuration = duration;
return this;
}
public RateLimiter build() {
return switch (algorithm) {
case TOKEN_BUCKET -> new TokenBucketLimiter(capacity, refillTokens, refillDuration);
case FIXED_WINDOW -> new FixedWindowLimiter(capacity, refillDuration);
case SLIDING_WINDOW_LOG -> new SlidingWindowLogLimiter(capacity, refillDuration);
};
}
}
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
class TokenBucketLimiter implements RateLimiter {
private final long capacity;
private final long refillTokens;
private final long refillNanos;
private final AtomicLong tokens;
private final AtomicLong lastRefillTime;
TokenBucketLimiter(long capacity, long refillTokens, Duration refillDuration) {
this.capacity = capacity;
this.refillTokens = refillTokens;
this.refillNanos = refillDuration.toNanos();
this.tokens = new AtomicLong(capacity);
this.lastRefillTime = new AtomicLong(System.nanoTime());
}
@Override
public boolean tryAcquire() {
refill();
while (true) {
long current = tokens.get();
if (current <= 0) return false;
if (tokens.compareAndSet(current, current - 1)) return true;
}
}
@Override
public void acquire() {
while (!tryAcquire()) {
LockSupport.parkNanos(refillNanos / refillTokens);
}
}
@Override
public long getAvailablePermits() {
refill();
return tokens.get();
}
private void refill() {
long now = System.nanoTime();
long last = lastRefillTime.get();
long elapsed = now - last;
if (elapsed < refillNanos) {
long tokensToAdd = (elapsed * refillTokens) / refillNanos;
if (tokensToAdd > 0 && lastRefillTime.compareAndSet(last, now)) {
long current = tokens.get();
long newTokens = Math.min(capacity, current + tokensToAdd);
tokens.set(newTokens);
}
} else {
if (lastRefillTime.compareAndSet(last, now)) {
tokens.set(capacity);
}
}
}
}
Your first algorithm is battle-tested under concurrent load. Next up, you will implement the Fixed Window and Sliding Window Log algorithms to give consumers three production-grade choices.
Implement Fixed Window and Sliding Window Log Algorithms
Your Token Bucket algorithm works well for smoothing bursty traffic, but real-world systems need different rate-limiting strategies for different use cases.
In this step, you'll implement two more algorithms: a Fixed Window counter that resets on a schedule, and a Sliding Window Log that tracks individual request timestamps for precision. Both use lock-free concurrency, just like the Token Bucket.
In this step, get ready to:
- Implement the Fixed Window counter algorithm with CAS-based window reset.
- Implement the Sliding Window Log algorithm with OOM protection.
- Write concurrent JUnit 5 tests proving thread safety with virtual threads.
Implement the Fixed Window Limiter
The Fixed Window algorithm divides time into equal-sized windows and allows a fixed number of requests per window. When the window expires, the counter resets to zero.
Why Fixed Window over Token Bucket?
Token Bucket smooths traffic gradually, while Fixed Window enforces a hard cap per time period. Fixed Window is simpler to reason about and works well for API quota enforcement where you want "100 requests per minute" as a strict boundary.
- Create a new file FixedWindowLimiter.java in throttle4j-core/src/main/java/com/github/dhruv0306/throttle4j/core/ and paste the following implementation:
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
class FixedWindowLimiter implements RateLimiter {
private final long maxRequests;
private final long windowNanos;
private final AtomicLong counter;
private final AtomicLong windowStart;
FixedWindowLimiter(long maxRequests, Duration windowDuration) {
this.maxRequests = maxRequests;
this.windowNanos = windowDuration.toNanos();
this.counter = new AtomicLong(0);
this.windowStart = new AtomicLong(System.nanoTime());
}
@Override
public boolean tryAcquire() {
long now = System.nanoTime();
long start = windowStart.get();
// Reset counter when current window has expired
if (now - start >= windowNanos) {
if (windowStart.compareAndSet(start, now)) {
counter.set(1);
return true;
}
}
// Optimistically increment, then roll back if over limit
long current = counter.incrementAndGet();
if (current <= maxRequests) return true;
counter.decrementAndGet();
return false;
}
@Override
public void acquire() {
// Spin-wait with parkNanos for virtual-thread friendliness
while (!tryAcquire()) {
long start = windowStart.get();
long remaining = windowNanos - (System.nanoTime() - start);
if (remaining > 0) LockSupport.parkNanos(remaining);
}
}
@Override
public long getAvailablePermits() {
long now = System.nanoTime();
long start = windowStart.get();
if (now - start >= windowNanos) return maxRequests;
return Math.max(0, maxRequests - counter.get());
}
}
What does this code do?
The Fixed Window limiter uses two AtomicLong fields to stay lock-free. One tracks the window start time, the other counts requests in the current window.
- compareAndSet(start, now) ensures only ONE thread resets the window when it expires. All other threads see the fresh start and move to the increment path.
- The optimistic increment-then-rollback pattern (incrementAndGet() followed by decrementAndGet() on failure) avoids a CAS loop while still being correct under contention.
- LockSupport.parkNanos() in acquire() parks until the window expires. This is virtual-thread friendly because it allows the scheduler to unmount the thread.
- Save FixedWindowLimiter.java.
You won't see a change yet. The next substep adds the Sliding Window Log, and then you'll run the full test suite.
Seeing a compile error in your IDE?
If your IDE shows a red underline on the class declaration, make sure the file is in the exact package path: throttle4j-core/src/main/java/com/github/dhruv0306/throttle4j/core/. The package statement must match the directory structure.
Also confirm the class visibility is package-private (no public modifier). The builder creates instances internally, so consumers never reference the class directly.
Help me debug my FixedWindowLimiter compile errors.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
class FixedWindowLimiter implements RateLimiter {
private final long maxRequests;
private final long windowNanos;
private final AtomicLong counter;
private final AtomicLong windowStart;
FixedWindowLimiter(long maxRequests, Duration windowDuration) {
this.maxRequests = maxRequests;
this.windowNanos = windowDuration.toNanos();
this.counter = new AtomicLong(0);
this.windowStart = new AtomicLong(System.nanoTime());
}
@Override
public boolean tryAcquire() {
long now = System.nanoTime();
long start = windowStart.get();
if (now - start >= windowNanos) {
if (windowStart.compareAndSet(start, now)) {
counter.set(1);
return true;
}
}
long current = counter.incrementAndGet();
if (current <= maxRequests) return true;
counter.decrementAndGet();
return false;
}
@Override
public void acquire() {
while (!tryAcquire()) {
long start = windowStart.get();
long remaining = windowNanos - (System.nanoTime() - start);
if (remaining > 0) LockSupport.parkNanos(remaining);
}
}
@Override
public long getAvailablePermits() {
long now = System.nanoTime();
long start = windowStart.get();
if (now - start >= windowNanos) return maxRequests;
return Math.max(0, maxRequests - counter.get());
}
}
Implement the Sliding Window Log Limiter
The Sliding Window Log algorithm stores a timestamp for every request and counts how many fall within the current rolling window. This gives the most accurate rate limiting but uses more memory per request.
Why ConcurrentLinkedDeque instead of a synchronized list?
A standard ArrayList or LinkedList throws ConcurrentModificationException under multi-threaded access. Wrapping them in synchronized blocks kills throughput.
ConcurrentLinkedDeque provides thread-safe add/remove from both ends without locks. Paired with an AtomicInteger size counter, you get O(1) size checks instead of the O(n) traversal that ConcurrentLinkedDeque.size() performs.
- Create a new file SlidingWindowLogLimiter.java in the same throttle4j-core/src/main/java/com/github/dhruv0306/throttle4j/core/ directory and paste this implementation:
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
class SlidingWindowLogLimiter implements RateLimiter {
private final long maxRequests;
private final long windowNanos;
private final int maxLogSize;
private final ConcurrentLinkedDeque<Long> timestamps;
private final AtomicInteger size;
SlidingWindowLogLimiter(long maxRequests, Duration windowDuration) {
this.maxRequests = maxRequests;
this.windowNanos = windowDuration.toNanos();
// OOM guard: hard cap prevents unbounded deque growth under malicious load
this.maxLogSize = (int) Math.min(maxRequests * 2, Integer.MAX_VALUE);
this.timestamps = new ConcurrentLinkedDeque<>();
this.size = new AtomicInteger(0);
}
@Override
public boolean tryAcquire() {
long now = System.nanoTime();
evictExpired(now);
// OOM guard: reject immediately if deque exceeds hard safety boundary
if (size.get() >= maxLogSize) return false;
// Check if within normal rate limit
if (size.get() >= maxRequests) return false;
timestamps.addLast(now);
int current = size.incrementAndGet();
// Roll back if another thread raced past the check
if (current > maxRequests) {
timestamps.pollLast();
size.decrementAndGet();
return false;
}
return true;
}
@Override
public void acquire() {
while (!tryAcquire()) {
LockSupport.parkNanos(windowNanos / maxRequests);
}
}
@Override
public long getAvailablePermits() {
evictExpired(System.nanoTime());
return Math.max(0, maxRequests - size.get());
}
private void evictExpired(long now) {
// Remove timestamps that have fallen outside the rolling window
while (true) {
Long head = timestamps.peekFirst();
if (head == null || now - head < windowNanos) break;
if (timestamps.pollFirst() != null) {
size.decrementAndGet();
}
}
}
}
What does this code do?
- evictExpired() drains old timestamps from the head of the deque. Only timestamps within the rolling window remain.
- maxLogSize (set to capacity * 2) is the OOM safety boundary. If millions of concurrent requests arrive faster than eviction can run, the deque would grow without bound and crash the JVM. This cap rejects immediately before adding to the deque.
- The optimistic-then-rollback pattern (addLast + incrementAndGet, rollback with pollLast + decrementAndGet) handles the race where multiple threads pass the size check simultaneously.
- Save SlidingWindowLogLimiter.java.
You won't see a runtime change yet. The next substep writes tests that exercise both new algorithms.
Import not resolving for ConcurrentLinkedDeque?
Make sure the import is java.util.concurrent.ConcurrentLinkedDeque (not java.util.concurrent.ConcurrentLinkedQueue). The deque variant supports both pollFirst() and pollLast() which you need for eviction and rollback.
Help me fix my SlidingWindowLogLimiter imports.
✔️ Awesome, I've got everything!
Great. Confirm both files are saved before moving to the tests.
ⓧ I'd like to double check the full code
package com.github.dhruv0306.throttle4j.core;
import java.time.Duration;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
class SlidingWindowLogLimiter implements RateLimiter {
private final long maxRequests;
private final long windowNanos;
private final int maxLogSize;
private final ConcurrentLinkedDeque<Long> timestamps;
private final AtomicInteger size;
SlidingWindowLogLimiter(long maxRequests, Duration windowDuration) {
this.maxRequests = maxRequests;
this.windowNanos = windowDuration.toNanos();
this.maxLogSize = (int) Math.min(maxRequests * 2, Integer.MAX_VALUE);
this.timestamps = new ConcurrentLinkedDeque<>();
this.size = new AtomicInteger(0);
}
@Override
public boolean tryAcquire() {
long now = System.nanoTime();
evictExpired(now);
if (size.get() >= maxLogSize) return false;
if (size.get() >= maxRequests) return false;
timestamps.addLast(now);
int current = size.incrementAndGet();
if (current > maxRequests) {
timestamps.pollLast();
size.decrementAndGet();
return false;
}
return true;
}
@Override
public void acquire() {
while (!tryAcquire()) {
LockSupport.parkNanos(windowNanos / maxRequests);
}
}
@Override
public long getAvailablePermits() {
evictExpired(System.nanoTime());
return Math.max(0, maxRequests - size.get());
}
private void evictExpired(long now) {
while (true) {
Long head = timestamps.peekFirst();
if (head == null || now - head < windowNanos) break;
if (timestamps.pollFirst() != null) {
size.decrementAndGet();
}
}
}
}
Write JUnit 5 tests for both algorithms
You need tests that prove the algorithms behave correctly under both normal conditions and extreme concurrent load. You will also verify the OOM guard rejects when the deque grows too large.
- Create a new test file WindowLimiterTest.java in throttle4j-core/src/test/java/com/github/dhruv0306/throttle4j/core/ and paste the following test class:
package com.github.dhruv0306.throttle4j.core;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class WindowLimiterTest {
// --- Fixed Window Tests ---
@Test
void fixedWindow_shouldAllowUpToMaxRequests() {
// Create a limiter allowing 5 requests per 1-second window
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.FIXED_WINDOW)
.capacity(5)
.refillRate(5, Duration.ofSeconds(1))
.build();
// First 5 requests should succeed
for (int i = 0; i < 5; i++) {
assertTrue(limiter.tryAcquire(), "Request " + i + " should succeed");
}
// 6th request should be rejected
assertFalse(limiter.tryAcquire(), "Request beyond capacity should be rejected");
}
@Test
void fixedWindow_shouldResetAfterWindowExpires() throws InterruptedException {
// Create a limiter with a very short window for testing
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.FIXED_WINDOW)
.capacity(3)
.refillRate(3, Duration.ofMillis(100))
.build();
// Exhaust the window
for (int i = 0; i < 3; i++) {
assertTrue(limiter.tryAcquire());
}
assertFalse(limiter.tryAcquire());
// Wait for window to expire
Thread.sleep(150);
// After window reset, requests should succeed again
assertTrue(limiter.tryAcquire(), "Should succeed after window reset");
}
// --- Sliding Window Log Tests ---
@Test
void slidingWindow_shouldEvictExpiredTimestamps() throws InterruptedException {
// Create a limiter with 200ms window
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.SLIDING_WINDOW_LOG)
.capacity(3)
.refillRate(3, Duration.ofMillis(200))
.build();
// Fill to capacity
for (int i = 0; i < 3; i++) {
assertTrue(limiter.tryAcquire());
}
assertFalse(limiter.tryAcquire());
// Wait for timestamps to expire
Thread.sleep(250);
// Eviction should free up permits
assertTrue(limiter.tryAcquire(), "Should succeed after eviction of expired timestamps");
}
@Test
void slidingWindow_oomGuard_shouldRejectWhenMaxLogSizeBreached() {
// Create a limiter with capacity 5 (maxLogSize = 10)
// We simulate by quickly filling past the normal limit
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.SLIDING_WINDOW_LOG)
.capacity(5)
.refillRate(5, Duration.ofSeconds(10))
.build();
// Fill to capacity
int granted = 0;
for (int i = 0; i < 20; i++) {
if (limiter.tryAcquire()) granted++;
}
// Should never exceed capacity
assertTrue(granted <= 5, "Grants should not exceed capacity: " + granted);
}
// --- Concurrent Stress Test with Virtual Threads ---
@Test
void concurrent_shouldNotOverGrantPermits() throws InterruptedException {
// Limiter allows 100 requests per second
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.SLIDING_WINDOW_LOG)
.capacity(100)
.refillRate(100, Duration.ofSeconds(1))
.build();
AtomicInteger totalGrants = new AtomicInteger(0);
int threadCount = 32;
int requestsPerThread = 50;
CountDownLatch latch = new CountDownLatch(threadCount);
// Spawn 32 virtual threads to hammer the limiter concurrently
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int t = 0; t < threadCount; t++) {
executor.submit(() -> {
for (int r = 0; r < requestsPerThread; r++) {
if (limiter.tryAcquire()) {
totalGrants.incrementAndGet();
}
}
latch.countDown();
});
}
latch.await();
}
// Total grants must not exceed capacity (100)
assertTrue(totalGrants.get() <= 100,
"Over-grant detected: " + totalGrants.get() + " grants for capacity 100");
}
}
What do these tests verify?
- fixedWindow_shouldResetAfterWindowExpires proves the CAS-based window reset works. After the window duration elapses, the counter resets and new requests flow through.
- slidingWindow_shouldEvictExpiredTimestamps confirms that evictExpired() correctly drains old timestamps and frees capacity.
- slidingWindow_oomGuard_shouldRejectWhenMaxLogSizeBreached verifies the hard boundary prevents grants from exceeding capacity.
- concurrent_shouldNotOverGrantPermits spawns 32 virtual threads (via Executors.newVirtualThreadPerTaskExecutor()) to prove no race conditions cause over-grants.
- Save WindowLimiterTest.java.
- Run the full build from the project root by running this command in your terminal:
mvn clean verify
- You should see BUILD SUCCESS with all tests passing. The output should show the test count including your new window limiter tests.
Tests failing or build error?
- If you see "cannot find symbol" errors for FixedWindowLimiter or SlidingWindowLogLimiter, confirm both files are saved in the correct package directory.
- If the virtual thread test fails with an UnsupportedOperationException, ensure you are running JDK 21 or later. Virtual threads require JDK 21+.
- If timing-based tests are flaky, increase the Thread.sleep() durations slightly. CI environments can be slower than local machines.
Help me debug my WindowLimiterTest failures.
✔️ Awesome, I've got everything!
Great. All three algorithms are implemented and tested.
ⓧ I'd like to double check the full code
package com.github.dhruv0306.throttle4j.core;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class WindowLimiterTest {
@Test
void fixedWindow_shouldAllowUpToMaxRequests() {
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.FIXED_WINDOW)
.capacity(5)
.refillRate(5, Duration.ofSeconds(1))
.build();
for (int i = 0; i < 5; i++) {
assertTrue(limiter.tryAcquire(), "Request " + i + " should succeed");
}
assertFalse(limiter.tryAcquire(), "Request beyond capacity should be rejected");
}
@Test
void fixedWindow_shouldResetAfterWindowExpires() throws InterruptedException {
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.FIXED_WINDOW)
.capacity(3)
.refillRate(3, Duration.ofMillis(100))
.build();
for (int i = 0; i < 3; i++) {
assertTrue(limiter.tryAcquire());
}
assertFalse(limiter.tryAcquire());
Thread.sleep(150);
assertTrue(limiter.tryAcquire(), "Should succeed after window reset");
}
@Test
void slidingWindow_shouldEvictExpiredTimestamps() throws InterruptedException {
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.SLIDING_WINDOW_LOG)
.capacity(3)
.refillRate(3, Duration.ofMillis(200))
.build();
for (int i = 0; i < 3; i++) {
assertTrue(limiter.tryAcquire());
}
assertFalse(limiter.tryAcquire());
Thread.sleep(250);
assertTrue(limiter.tryAcquire(), "Should succeed after eviction of expired timestamps");
}
@Test
void slidingWindow_oomGuard_shouldRejectWhenMaxLogSizeBreached() {
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.SLIDING_WINDOW_LOG)
.capacity(5)
.refillRate(5, Duration.ofSeconds(10))
.build();
int granted = 0;
for (int i = 0; i < 20; i++) {
if (limiter.tryAcquire()) granted++;
}
assertTrue(granted <= 5, "Grants should not exceed capacity: " + granted);
}
@Test
void concurrent_shouldNotOverGrantPermits() throws InterruptedException {
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.SLIDING_WINDOW_LOG)
.capacity(100)
.refillRate(100, Duration.ofSeconds(1))
.build();
AtomicInteger totalGrants = new AtomicInteger(0);
int threadCount = 32;
int requestsPerThread = 50;
CountDownLatch latch = new CountDownLatch(threadCount);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int t = 0; t < threadCount; t++) {
executor.submit(() -> {
for (int r = 0; r < requestsPerThread; r++) {
if (limiter.tryAcquire()) {
totalGrants.incrementAndGet();
}
}
latch.countDown();
});
}
latch.await();
}
assertTrue(totalGrants.get() <= 100,
"Over-grant detected: " + totalGrants.get() + " grants for capacity 100");
}
}
All three rate-limiting algorithms are implemented and battle-tested under concurrent load. Next up, you'll build the Spring Boot auto-configuration module so consumers can enable rate limiting with a few lines of YAML.
Build the Spring Boot Auto-Configuration Module
Your three rate-limiting algorithms are built, tested, and passing under concurrent load. The core module is solid.
Now you need to make throttle4j easy to use for Spring Boot developers. Instead of forcing them to manually construct a RateLimiter in every application, you will build an auto-configuration module that wires everything from a YAML file. A single property toggles rate limiting on, and the algorithm, capacity, and refill rate come from standard Spring Boot configuration.
In this step, get ready to:
- Create the properties and auto-configuration classes for YAML-driven rate limiting.
- Register the auto-configuration using the modern Spring Boot 3.x discovery file.
- Write an integration test to verify the bean is created with the correct configuration.
Create the properties and auto-configuration classes
Spring Boot's @ConfigurationProperties annotation binds YAML or .properties values to a Java class automatically. You will use this to let consumers configure the algorithm, capacity, and refill rate without writing any Java code.
- Create the file throttle4j-spring/src/main/java/com/github/dhruv0306/throttle4j/spring/Throttle4jProperties.java and paste the following code:
package com.github.dhruv0306.throttle4j.spring;
import com.github.dhruv0306.throttle4j.core.Algorithm;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
// Binds all "throttle4j.*" properties from application.yml to this class
@ConfigurationProperties(prefix = "throttle4j")
public class Throttle4jProperties {
private boolean enabled = false;
private Algorithm algorithm = Algorithm.TOKEN_BUCKET;
private long capacity = 100;
private long refillTokens = 10;
private Duration refillDuration = Duration.ofSeconds(1);
// Standard getters and setters for Spring Boot property binding
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public Algorithm getAlgorithm() { return algorithm; }
public void setAlgorithm(Algorithm algorithm) { this.algorithm = algorithm; }
public long getCapacity() { return capacity; }
public void setCapacity(long capacity) { this.capacity = capacity; }
public long getRefillTokens() { return refillTokens; }
public void setRefillTokens(long refillTokens) { this.refillTokens = refillTokens; }
public Duration getRefillDuration() { return refillDuration; }
public void setRefillDuration(Duration refillDuration) { this.refillDuration = refillDuration; }
}
What does this code do?
The @ConfigurationProperties(prefix = "throttle4j") annotation tells Spring Boot to bind any property starting with throttle4j. to the matching field in this class. For example, throttle4j.capacity=50 in YAML maps to the capacity field.
Default values are set so consumers only need to override what they want to change. The enabled field defaults to false so the rate limiter never activates unless the consumer explicitly opts in.
- Save Throttle4jProperties.java.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
package com.github.dhruv0306.throttle4j.spring;
import com.github.dhruv0306.throttle4j.core.Algorithm;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "throttle4j")
public class Throttle4jProperties {
private boolean enabled = false;
private Algorithm algorithm = Algorithm.TOKEN_BUCKET;
private long capacity = 100;
private long refillTokens = 10;
private Duration refillDuration = Duration.ofSeconds(1);
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public Algorithm getAlgorithm() { return algorithm; }
public void setAlgorithm(Algorithm algorithm) { this.algorithm = algorithm; }
public long getCapacity() { return capacity; }
public void setCapacity(long capacity) { this.capacity = capacity; }
public long getRefillTokens() { return refillTokens; }
public void setRefillTokens(long refillTokens) { this.refillTokens = refillTokens; }
public Duration getRefillDuration() { return refillDuration; }
public void setRefillDuration(Duration refillDuration) { this.refillDuration = refillDuration; }
}
Now create the auto-configuration class that uses these properties to build the RateLimiter bean.
- Create the file throttle4j-spring/src/main/java/com/github/dhruv0306/throttle4j/spring/Throttle4jAutoConfiguration.java and paste the following code:
package com.github.dhruv0306.throttle4j.spring;
import com.github.dhruv0306.throttle4j.core.RateLimiter;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
// Activates only when throttle4j.enabled=true in the application config
@AutoConfiguration
@ConditionalOnProperty(prefix = "throttle4j", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(Throttle4jProperties.class)
public class Throttle4jAutoConfiguration {
// Creates a RateLimiter bean using the fluent builder and bound properties
@Bean
public RateLimiter rateLimiter(Throttle4jProperties properties) {
return RateLimiter.builder()
.algorithm(properties.getAlgorithm())
.capacity(properties.getCapacity())
.refillRate(properties.getRefillTokens(), properties.getRefillDuration())
.build();
}
}
What does this code do?
The @AutoConfiguration annotation marks this class as a Spring Boot auto-configuration candidate. Spring Boot discovers it at startup and evaluates its conditions.
The @ConditionalOnProperty annotation ensures the entire configuration only loads when throttle4j.enabled=true is present. If the property is missing or false, the bean is never created and there is zero runtime overhead.
The @EnableConfigurationProperties annotation registers Throttle4jProperties as a bean so Spring can inject it into the rateLimiter() factory method.
- Save Throttle4jAutoConfiguration.java.
Seeing import errors in your IDE?
If your IDE shows red squiggles on the Spring Boot imports, make sure you have the throttle4j-spring/pom.xml properly saved with the spring-boot-starter dependency. Reload your Maven project (in IntelliJ: right-click the parent pom.xml and select Maven > Reload project).
help me fix Spring Boot import errors in my throttle4j-spring module
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
package com.github.dhruv0306.throttle4j.spring;
import com.github.dhruv0306.throttle4j.core.RateLimiter;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
@ConditionalOnProperty(prefix = "throttle4j", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(Throttle4jProperties.class)
public class Throttle4jAutoConfiguration {
@Bean
public RateLimiter rateLimiter(Throttle4jProperties properties) {
return RateLimiter.builder()
.algorithm(properties.getAlgorithm())
.capacity(properties.getCapacity())
.refillRate(properties.getRefillTokens(), properties.getRefillDuration())
.build();
}
}
Register the auto-configuration for Spring Boot discovery
Spring Boot 3.x uses a specific file to discover auto-configuration classes. Without this registration file, Spring Boot will never find your Throttle4jAutoConfiguration class at startup.
Why not spring.factories?
Older Spring Boot versions (2.x) used a META-INF/spring.factories file for auto-configuration discovery. Spring Boot 3.x replaced this with the AutoConfiguration.imports file for better performance and clarity. Each line contains one fully-qualified class name.
- Create the directory path throttle4j-spring/src/main/resources/META-INF/spring/ inside your project.
- Create a file named org.springframework.boot.autoconfigure.AutoConfiguration.imports inside that directory and paste the following content:
com.github.dhruv0306.throttle4j.spring.Throttle4jAutoConfiguration
What does this file do?
When a Spring Boot application starts, it scans the classpath for META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports files. Every class listed in this file is evaluated as a potential auto-configuration candidate. Your single entry tells Spring Boot to load Throttle4jAutoConfiguration and evaluate its @ConditionalOnProperty condition.
- Save the file.
- Confirm the full path is throttle4j-spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. The path must be exact for Spring Boot to discover it.
Auto-configuration not detected later?
The most common cause is a typo in the directory structure. Double check you have META-INF/spring/ (not META-INF/springs/ or META-INF/ directly). Also confirm the filename matches exactly: org.springframework.boot.autoconfigure.AutoConfiguration.imports.
help me debug why my Spring Boot auto-configuration is not being discovered
✔️ Awesome, I've got everything!
Great. The Spring module is now fully wired for auto-discovery.
ⓧ I'd like to double check the full code
com.github.dhruv0306.throttle4j.spring.Throttle4jAutoConfiguration
Write an integration test and verify the build
A good library ships with tests proving its auto-configuration works. You will use @SpringBootTest with inline properties to spin up a test application context and verify the RateLimiter bean is created correctly.
- Create the file throttle4j-spring/src/test/java/com/github/dhruv0306/throttle4j/spring/Throttle4jAutoConfigurationTest.java and paste the following code:
package com.github.dhruv0306.throttle4j.spring;
import com.github.dhruv0306.throttle4j.core.RateLimiter;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import static org.junit.jupiter.api.Assertions.*;
// Boots a test context with throttle4j properties enabled
@SpringBootTest(properties = {
"throttle4j.enabled=true",
"throttle4j.algorithm=TOKEN_BUCKET",
"throttle4j.capacity=50"
})
class Throttle4jAutoConfigurationTest {
// Minimal Spring Boot app for the test context
@Configuration
@EnableAutoConfiguration
static class TestConfig {}
@Autowired
private RateLimiter rateLimiter;
@Test
void rateLimiterBeanIsCreatedWithCorrectCapacity() {
// Verify the bean was created by auto-configuration
assertNotNull(rateLimiter);
// Verify it respects the configured capacity of 50
assertEquals(50, rateLimiter.getAvailablePermits());
}
}
What does this test do?
The @SpringBootTest(properties = {...}) annotation boots a minimal Spring application context with the specified properties injected into the environment. The nested TestConfig class acts as a minimal application class for the test.
Because throttle4j.enabled=true is set, Spring Boot discovers your Throttle4jAutoConfiguration and creates a RateLimiter bean. The test then asserts the bean exists and has the correct capacity of 50 permits.
- Save Throttle4jAutoConfigurationTest.java.
- Run the full build from the parent project directory by running this command:
mvn clean verify
- You should see BUILD SUCCESS in the terminal output with all tests passing for both throttle4j-core and throttle4j-spring.
Tests failing or build errors?
If you see a compilation error about missing classes, verify your throttle4j-spring/pom.xml has both spring-boot-starter (provided scope) and spring-boot-starter-test (test scope) as dependencies.
If the test fails with an assertion error on getAvailablePermits(), confirm your Throttle4jProperties has the capacity field and its getter matches the property name exactly.
help me debug my throttle4j-spring test failures
Your Spring Boot auto-configuration module is complete. Any Spring Boot application can now add throttle4j-spring as a dependency, set a few YAML properties, and get a production-ready rate limiter without writing any wiring code. Next up, you will publish this library to GitHub Packages so other developers can actually pull it in.
Publish to GitHub Packages with GitHub Actions
Your Spring Boot auto-configuration module is tested and working. Both throttle4j-core and throttle4j-spring pass all their tests locally.
Now it is time to ship. In this step, you will create a GitHub Actions workflow that automatically builds, tests, and publishes your library to GitHub Packages every time you push a version tag. Then you will prove the published artifact works by consuming it from a separate project.
In this step, get ready to:
- Create a GitHub Actions CI/CD workflow triggered on version tags.
- Tag and push your first release to publish both modules.
- Verify the published package by consuming it from a separate project.
Create the GitHub Actions workflow file
GitHub Actions uses YAML workflow files stored in a .github/workflows/ directory. When an event matches the trigger (in this case, a version tag push), GitHub spins up a runner and executes your steps.
- Create the workflow directory from your project root by running this command:
mkdir -p .github/workflows
- Create a new file called publish.yml inside .github/workflows/ and paste the following content:
name: Publish to GitHub Packages
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Publish package
run: mvn --batch-mode deploy -Dgpg.skip=true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
What does this workflow do?
- on: push: tags: - 'v*' triggers the workflow only when you push a tag starting with v (like v1.0.0).
- permissions: contents: read and packages: write grant the runner read access to your code and write access to publish packages.
- actions/setup-java@v4 installs JDK 17 (Temurin distribution) and configures Maven's settings.xml so that GITHUB_TOKEN is used for authentication.
- mvn --batch-mode deploy -Dgpg.skip=true builds both modules, runs all tests, and pushes the JARs to GitHub Packages. The -Dgpg.skip=true flag disables GPG signing since GitHub Actions authenticates with the token instead of GPG keys.
Why skip GPG signing?
Enterprise Maven setups often require GPG-signed artifacts for repository managers like Nexus or Artifactory. GitHub Packages authenticates via GITHUB_TOKEN instead, so GPG signing is unnecessary and would fail without private key setup.
The <gpg.skip>true</gpg.skip> property in your parent POM and the -Dgpg.skip=true flag ensure the deploy phase never attempts signing.
- Save publish.yml.
- Confirm the file exists by running:
ls .github/workflows/
You should see publish.yml in the output.
Don't see the file?
Make sure you created the nested directory with mkdir -p .github/workflows (note the leading dot). A missing dot means the directory is not hidden and GitHub Actions will not detect it.
Help me debug my directory structure
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
name: Publish to GitHub Packages
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Publish package
run: mvn --batch-mode deploy -Dgpg.skip=true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Tag and push the first release
The workflow triggers on version tag pushes. You need to commit all your code, push it to GitHub, then create and push a v1.0.0 tag.
- Stage and commit all project files by running these commands:
git add -A
git commit -m "Add throttle4j library v1.0.0"
- Push your code to GitHub by running:
git push origin main
Why push before tagging?
The workflow checks out your code at the tagged commit. If you tag before pushing, the tag points to a commit that only exists locally. Pushing the code first ensures the runner can access all your files.
- Create the version tag and push it to GitHub by running:
git tag v1.0.0
git push origin v1.0.0
Pushing the tag triggers the workflow automatically.
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of the repository page.
- You should see a workflow run named Publish to GitHub Packages triggered by the v1.0.0 tag. Wait for it to complete with a green checkmark.
Workflow failed with 403 Forbidden?
This usually means your groupId does not match the repository owner. GitHub Packages requires the groupId (e.g., com.github.dhruv0306) to align with the repository owner in the distributionManagement URL. Check that both are consistent in your parent pom.xml.
Also verify that your repository's Actions permissions allow write access to packages. Go to Settings, then Actions, then General and confirm Workflow permissions is set to Read and write permissions.
Help me debug my 403 Forbidden deploy error
- Once the workflow succeeds, click the Code tab, then look at the right sidebar for the Packages section.
- You should see throttle4j-core and throttle4j-spring listed as published packages.
Verify with a consumer project
The real test of a library is whether another project can pull it in and use it. You will create a minimal consumer project in a separate directory to prove the published artifact resolves correctly from GitHub Packages.
GitHub Packages requires authentication even for reading packages. You need a Personal Access Token (classic) with the read:packages scope configured in your local Maven settings.
- Open GitHub in your browser.
- Click your profile photo in the top right, then click Settings.
- Scroll down in the left sidebar and click Developer settings.
- Click Personal access tokens, then Tokens (classic).
- Click Generate new token, then Generate new token (classic).
- Give the token a note like throttle4j-consumer.
- Check the read:packages scope.
- Click Generate token and copy the token value immediately. You will not see it again.
Why a classic PAT?
GitHub Packages only supports authentication using a personal access token (classic). Fine-grained tokens do not yet support accessing Packages (this is listed as a known feature gap in GitHub's documentation). The read:packages scope grants read-only access to download and install packages.
- Create or edit your Maven settings file at ~/.m2/settings.xml with the following content, replacing the username and token placeholders with your actual values:
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">
<activeProfiles>
<activeProfile>github</activeProfile>
</activeProfiles>
<profiles>
<profile>
<id>github</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
</repository>
<repository>
<id>github</id>
<url>https://maven.pkg.github.com/Dhruv0306/throttle4j</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
<servers>
<server>
<id>github</id>
<username>your-github-username</username>
<password>your-personal-access-token</password>
</server>
</servers>
</settings>
What does this settings file do?
The <server> block with id github stores your credentials. The <repository> block with the same id maps to those credentials so Maven authenticates when downloading from GitHub Packages.
The <activeProfile> element ensures this profile is always active without needing a command-line flag.
- Navigate to your Desktop and create a consumer project directory by running:
cd ~/Desktop
mkdir throttle4j-consumer
cd throttle4j-consumer
- Create a pom.xml file in the throttle4j-consumer directory with the following content:
<?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>
<groupId>com.example</groupId>
<artifactId>throttle4j-consumer</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<dependencies>
<!-- Pull throttle4j-core from GitHub Packages -->
<dependency>
<groupId>com.github.dhruv0306</groupId>
<artifactId>throttle4j-core</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>
- Create the source directory structure by running:
mkdir -p src/main/java/com/example
- Create a file called App.java inside src/main/java/com/example/ with the following content:
package com.example;
import com.github.dhruv0306.throttle4j.core.Algorithm;
import com.github.dhruv0306.throttle4j.core.RateLimiter;
import java.time.Duration;
public class App {
public static void main(String[] args) {
// Create a rate limiter using the published library
RateLimiter limiter = RateLimiter.builder()
.algorithm(Algorithm.TOKEN_BUCKET)
.capacity(10)
.refillRate(5, Duration.ofSeconds(1))
.build();
System.out.println("Permits available: " + limiter.getAvailablePermits());
System.out.println("Acquired: " + limiter.tryAcquire());
System.out.println("Permits after acquire: " + limiter.getAvailablePermits());
}
}
What does this consumer do?
This minimal class imports RateLimiter and Algorithm from your published throttle4j-core library, creates a Token Bucket limiter with the fluent builder, and exercises getAvailablePermits() and tryAcquire().
If this compiles and runs, the published artifact resolved correctly from GitHub Packages.
- Compile and run the consumer project by running:
mvn compile exec:java -Dexec.mainClass="com.example.App" -q
You should see output showing the permits count and a successful acquire:
Permits available: 10
Acquired: true
Permits after acquire: 9
Seeing a dependency resolution error?
If Maven cannot find throttle4j-core, check these common causes:
- Your ~/.m2/settings.xml has the correct repository URL: https://maven.pkg.github.com/Dhruv0306/throttle4j.
- The server <id> in settings.xml matches the repository <id> (both must be github).
- Your PAT has the read:packages scope and is a classic token (not fine-grained).
- The GitHub Actions workflow completed successfully. If it failed, the artifact was never published.
Help me troubleshoot my dependency resolution error
Your library is live. Anyone with access to your GitHub repository can now pull throttle4j-core and throttle4j-spring as Maven dependencies. Next up, you will tackle the Secret Mission and add observability to your rate limiter.
Secret mission
Add a Metrics Listener API
Your rate limiter works, but in production you'd want to know how many requests are being granted, rejected, and how long callers are waiting. Can you add an observability layer using the Observer pattern and high-contention atomic counters?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and publishes to GitHub Packages (free for public repositories). There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local throttle4j project folder on your Desktop.
- Local consumer test project folder created in Step 5.
- GitHub repository Dhruv0306/throttle4j.
- Published packages (throttle4j-core and throttle4j-spring) on GitHub Packages.
Keep everything running
No action needed. Choose this if you're still actively building on throttle4j or want to keep your published library available for others to consume.
- Your local project folder stays on your Desktop.
- Your GitHub repository remains public and accessible.
- Your published packages remain available on GitHub Packages at no cost for public repositories.
Pause - I'll come back to this later
There are no running processes to stop. Your project is already in a paused state since Maven and GitHub Actions only run when you trigger them.
- Your local files, GitHub repository, and published packages can all stay as they are with no ongoing cost.
- When you return, open your terminal, navigate to the throttle4j folder, and run mvn clean verify to confirm everything still compiles.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Delete local project folders
- Delete the throttle4j project folder and the consumer test project folder by running these commands:
cd ~/Desktop
rm -rf throttle4j
Also delete the consumer test project folder you created in Step 5 from the same location.
Delete published packages on GitHub
- Go to your throttle4j repository on GitHub.
- Click the package name under the Packages section on the right sidebar.
- Click Package settings.
- Scroll to the Danger Zone and click Delete this package.
- Repeat for both throttle4j-core and throttle4j-spring.
Delete the GitHub repository
- In your throttle4j repository, click Settings.
- Scroll to the bottom to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Nice Work!
Nice Work!
You just designed, tested, and published a production-grade Java rate-limiter library from scratch. That is real library-author work.
What you learned:
- Built a two-module Maven library (throttle4j-core + throttle4j-spring) implementing three lock-free rate-limiting algorithms using AtomicLong, CAS loops, and ConcurrentLinkedDeque.
- Published the library to GitHub Packages via a GitHub Actions CI/CD pipeline triggered on version tag push.
- Built a Spring Boot 3.x auto-configuration starter using the modern AutoConfiguration.imports registration so consumers can configure rate limiting through YAML properties.
- Secret Mission: Added a metrics/listener API using LongAdder for high-contention observability tracking of grants, rejections, and wait times.
Ready to quiz yourself?