Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 51

Verifying HTTP Response Counts Under Load

Recommended resources

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.

Rate Limiter Filter Interaction with API Endpoint
This diagram illustrates the fundamental behavior we need to verify: some requests get a `200 OK` status, while those exceeding the threshold receive a `429 Too Many Requests` status.

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:

  1. Modify the Callable: Instead of returning Void, our task will now return the integer status code of the HTTP response.
  2. Aggregate the results: In the main thread, we'll collect the status codes from each Future and count them. Using AtomicInteger is 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:

  1. Add Atomic Counters: At the class level, declare AtomicInteger instances 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);
    
  2. Update the Callable Task: Change the Callable<Void> to Callable<Integer>. Inside the call method, execute the request and return response.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();
        }
    }
    
  3. Process the Results: In your main loop that iterates over the Future objects, 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:

  1. Ensure your Spring Boot application is running.
  2. Update the RATE_LIMIT constant in LoadTestUtility.java to match the limit you've configured in your application.yml for the /api/greet endpoint.
  3. Run the main method. Observe the output and confirm that the correctness assertions pass. Try changing the RATE_LIMIT constant 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.
  • AtomicInteger provides a simple and efficient mechanism for aggregating counts from concurrent operations.
  • Using a standard assertion library like JUnit's Assertions allows 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.