Hello! Welcome back to your course on building a rate limiter engine.
In our last lesson, we delved into the nuances of performance analysis by calculating and interpreting latency percentiles. You learned how p50, p95, and p99 provide a rich, data-backed story about your system's behavior under load—a story that simple averages can't tell.
While performance is critical, it's only half the picture. A fast rate limiter that doesn't correctly enforce its limits is a liability. In this lesson, we will pivot from performance analysis to functional correctness. You'll enhance your load testing tool to not just measure speed but to rigorously verify that your rate limiter behaves exactly as expected, even under heavy, concurrent traffic.
Your learning outcome for this lesson:
You will learn to write assertions verifying that HTTP 200 and 429 response counts match configured limits under load.
1. From Observation to Assertion
When you test a rate limiter, you have a clear expectation. If the limit is 10 requests, and you send 12, you expect 10 to succeed and 2 to fail. This is the core contract of the component.

Manually checking the output, as you'll see in the following video clip, is a great first step to confirm this behavior.
I Made a Rate Limiter using Springboot in 1.5 Hours
Watch this brief demonstration by Sanket Singh. He has a rate limit of 10 and sends 12 requests, then visually inspects the output to confirm the first 10 passed and the last 2 were blocked. Our goal today is to automate this verification.
Watch from 01:31:16 to 01:35:07. Notice how the test is based on a clear expectation of success and failure counts.
This manual check is useful, but it doesn't scale and isn't suitable for automated testing pipelines. To build a robust system, we need to turn these expectations into automated assertions that can run repeatably and fail loudly if the contract is broken.
2. Enhancing the LoadTestUtility to Capture Responses
Your existing LoadTestUtility.java is designed to measure latency. To verify correctness, we need to modify it to capture the HTTP status code from each response.
We can achieve this by making two key changes:
- Modify the
Callable: Instead of returningVoid, our task will now return the integer status code of the HTTP response. - Aggregate the results: In the main thread, we'll collect the status codes from each
Futureand count them. UsingAtomicIntegeris ideal for this, as it provides a thread-safe way to increment counters from the main loop without needing explicit locks.
Here is the plan for modifying your LoadTestUtility.java:
-
Add Atomic Counters: At the class level, declare
AtomicIntegerinstances to track the counts of successful (200) and rate-limited (429) responses.private static final AtomicInteger successCount = new AtomicInteger(0); private static final AtomicInteger deniedCount = new AtomicInteger(0); -
Update the
CallableTask: Change theCallable<Void>toCallable<Integer>. Inside thecallmethod, execute the request and returnresponse.statusCode().// Before // static class RequestTask implements Callable<Void> { ... response.body(); return null; } // After static class RequestTask implements Callable<Integer> { // ... HttpClient setup ... @Override public Integer call() throws Exception { // ... inside the call method ... HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); return response.statusCode(); } } -
Process the Results: In your main loop that iterates over the
Futureobjects, retrieve the integer status code and update the appropriate counter.for (Future<Integer> future : futures) { try { int statusCode = future.get(); // This is the change if (statusCode == 200) { successCount.incrementAndGet(); } else if (statusCode == 429) { deniedCount.incrementAndGet(); } } catch (InterruptedException | ExecutionException e) { // ... } }
3. Asserting Correctness with JUnit
After the load test completes and all Futures are processed, successCount and deniedCount will hold the final tallies. Now, we can assert that these counts match our expectations.
While our load tester is a standalone application, we can use the assertion syntax from a standard testing framework like JUnit to check our results. The articles you may see on testing Spring applications often use MockMvc within a @SpringBootTest context, but the core principle of asserting an expected outcome remains the same.
Rate Limiter in Distributed Systems: Protecting Your APIs with Java ...
This article demonstrates how to test a rate limiter within the Spring MVC testing framework. While it uses MockMvc for sending requests, pay close attention to the assertion logic.
Review the test methods in the section for RateLimiterTest.java. Notice how methods like shouldAllowRequestsWithinLimit and shouldBlockRequestsExceedingLimit use andExpect(status().is(HttpStatus.OK.value())) and andExpect(status().is(429)) to assert the expected HTTP status for each request.
In our LoadTestUtility, we won't be asserting on individual requests but on the final aggregate counts. To do this, you'll need to add the JUnit Jupiter API dependency to your build.gradle file to use its Assertions class.
// build.gradle
dependencies {
// ... other dependencies
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2'
}
Make sure to use testImplementation if you place the utility under src/test/java, or implementation if you keep it under src/main/java.
With the dependency added, you can write the final assertions at the end of your main method.
import org.junit.jupiter.api.Assertions;
// ... at the end of the main method ...
System.out.printf("\nTotal successful requests (200 OK): %d\n", successCount.get());
System.out.printf("Total denied requests (429 Too Many Requests): %d\n", deniedCount.get());
// Define your expectations
int expectedSuccess = RATE_LIMIT;
int expectedDenied = TOTAL_REQUESTS - RATE_LIMIT;
// Assert that the actual counts match the expected counts
Assertions.assertEquals(expectedSuccess, successCount.get(), "The number of successful requests does not match the rate limit.");
Assertions.assertEquals(expectedDenied, deniedCount.get(), "The number of denied requests is incorrect.");
System.out.println("\n✅ Correctness assertions passed!");
This block clearly defines the expected outcome based on your configuration and uses Assertions.assertEquals to programmatically verify it. If the counts don't match, the program will terminate with an AssertionFailedError, giving you immediate, unambiguous feedback that your rate limiter is not behaving correctly.
4. Your Turn: Implement the Verification
It's time to put these concepts into practice. Below is the complete, updated LoadTestUtility.java. Replace your existing utility with this version.
LoadTestUtility.java
import org.junit.jupiter.api.Assertions;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class LoadTestUtility {
// --- Configuration ---
private static final String ENDPOINT_URL = "http://localhost:8080/api/greet";
private static final int TOTAL_REQUESTS = 100;
private static final int CONCURRENT_THREADS = 10;
// Set this to match your rate limiter's configuration
private static final int RATE_LIMIT = 20;
// --- Counters and Collectors ---
private static final AtomicInteger successCount = new AtomicInteger(0);
private static final AtomicInteger deniedCount = new Atomic_Integer(0);
private static final Queue<Long> latencies = new ConcurrentLinkedQueue<>();
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_THREADS);
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
List<Callable<Integer>> tasks = new ArrayList<>();
for (int i = 0; i < TOTAL_REQUESTS; i++) {
tasks.add(new RequestTask(client, latencies));
}
System.out.printf("Starting load test with %d requests...\n", TOTAL_REQUESTS);
long startTime = System.nanoTime();
// Submit all tasks
List<Future<Integer>> futures = executor.invokeAll(tasks);
// Process results
for (Future<Integer> future : futures) {
try {
int statusCode = future.get();
if (statusCode == 200) {
successCount.incrementAndGet();
} else if (statusCode == 429) {
deniedCount.incrementAndGet();
}
} catch (ExecutionException e) {
System.err.println("Request task failed: " + e.getCause().getMessage());
}
}
long endTime = System.nanoTime();
executor.shutdown();
// --- Print Results ---
System.out.println("\n--- Load Test Results ---");
System.out.printf("Total time taken: %.3f seconds\n", (endTime - startTime) / 1_000_000_000.0);
System.out.printf("\nTotal successful requests (200 OK): %d\n", successCount.get());
System.out.printf("Total denied requests (429 Too Many Requests): %d\n", deniedCount.get());
// --- Correctness Assertions ---
int expectedSuccess = RATE_LIMIT;
int expectedDenied = TOTAL_REQUESTS - RATE_LIMIT;
Assertions.assertEquals(expectedSuccess, successCount.get(), "The number of successful requests does not match the rate limit.");
Assertions.assertEquals(expectedDenied, deniedCount.get(), "The number of denied requests is incorrect.");
System.out.println("\n✅ Correctness assertions passed!");
// --- Performance Analysis ---
printLatencyPercentiles(latencies, 0.50, 0.95, 0.99);
}
static class RequestTask implements Callable<Integer> {
private final HttpClient client;
private final Queue<Long> latencies;
public RequestTask(HttpClient client, Queue<Long> latencies) {
this.client = client;
this.latencies = latencies;
}
@Override
public Integer call() throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(ENDPOINT_URL))
.GET()
.build();
long requestStartTime = System.nanoTime();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
long requestEndTime = System.nanoTime();
latencies.add(requestEndTime - requestStartTime);
return response.statusCode();
}
}
// (Include the printLatencyPercentiles method from the previous lesson here)
private static void printLatencyPercentiles(Queue<Long> latencies, double... percentiles) {
// ... implementation from last lesson ...
}
}
To run this test:
- Ensure your Spring Boot application is running.
- Update the
RATE_LIMITconstant inLoadTestUtility.javato match the limit you've configured in yourapplication.ymlfor the/api/greetendpoint. - Run the
mainmethod. Observe the output and confirm that the correctness assertions pass. Try changing theRATE_LIMITconstant to an incorrect value and see the test fail.
Conclusion
You have now successfully extended your load testing framework to perform correctness verification. This is a crucial step in building confidence in your rate limiter's logic. By automatically counting and asserting response codes, you have created a powerful regression test that ensures your limiter's core functionality remains intact as you continue to develop your project.
Key Takeaways:
- Verifying functional correctness under load is as important as measuring performance.
- You can adapt a load testing utility to capture functional results, such as HTTP status codes, by modifying the tasks run by the
ExecutorService. AtomicIntegerprovides a simple and efficient mechanism for aggregating counts from concurrent operations.- Using a standard assertion library like JUnit's
Assertionsallows you to programmatically validate that your system's behavior matches your expectations.
Next Steps
Your rate limiter is now tested for both performance and correctness, making it robust and reliable. The final piece of the puzzle for a production-grade service is observability—the ability to monitor its behavior in real-time.
In our next lesson, we will begin the final module of this course by integrating Micrometer with Spring Boot for custom metrics collection. You'll learn to instrument your rate limiter to export metrics on allowed and denied requests, making its internal state visible to monitoring systems.