Skip to main content

Welcome to a Grasp lesson

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

Lesson 13

Implementing `RateLimitKeyResolver`

Recommended resources

Hello! Welcome back to our project of building a production-grade rate limiter.

In the last lesson, we established a crucial piece of our architecture: the RateLimiter strategy interface. This design allows us to abstract the how of rate limiting—whether we use a fixed window, token bucket, or another algorithm. Its allowRequest(String key, ...) method takes a key to identify who is making the request.

Today, we will focus on designing the component that generates that key. This is the second major abstraction in our system, and it answers the complementary question: "Who is this request from?" By separating the logic of identifying a client from the logic of limiting a client, we build a highly flexible, multi-tenant system capable of enforcing different rules for different users, IPs, or API keys.

Your learning outcome for this lesson:

By the end of this lesson, you will be able to design a RateLimitKeyResolver interface for flexible client identification.



1. The Challenge of Client Identification

A rate limiter needs to track usage per client. But who, or what, is a "client"? It could be:

  • A user's IP address.
  • A specific authenticated user ID.
  • An API key assigned to a partner service.
  • The API endpoint being called.
  • A combination of the above (e.g., limit user X to 10 calls per minute on the /profile endpoint).

Hard-coding the logic to extract these identifiers into our main rate-limiting component would lead to a rigid system, full of if-else statements, that is difficult to extend. Does this problem sound familiar? It's the exact same challenge we faced with the algorithms themselves.

Once again, the Strategy Design Pattern provides the solution. We will define an interface that represents the contract for "resolving a key from a request" and create multiple concrete implementations for different identification strategies.

Class Diagram of Strategy Design Pattern
This UML diagram for the Strategy Pattern, which we saw in the previous lesson, is just as applicable here. Our **Strategy Interface** will be `RateLimitKeyResolver`, and our **Concrete Strategies** will be classes like `IpAddressResolver`, `ApiKeyResolver`, etc. The **Context** will be our Spring `HandlerInterceptor`, which uses the chosen resolver.


2. Designing the RateLimitKeyResolver Interface

To design our interface, we can look at existing, battle-tested implementations for inspiration. Spring Cloud Gateway, a popular API gateway project, includes this exact concept.

RequestRateLimiter GatewayFilter Factory

The Spring Cloud Gateway documentation introduces a KeyResolver interface that is functionally identical to what we need. It defines the contract for deriving a key to be used for rate limiting.

Please read the initial paragraphs about the keyResolver and examine the KeyResolver.java interface definition. Also, look at the Java configuration example that shows a simple implementation returning a query parameter. This demonstrates how a KeyResolver provides a pluggable strategy.

The Spring Cloud Gateway example uses ServerWebExchange and Mono because it's built on a reactive stack (Spring WebFlux). Since our project uses the traditional servlet stack (Spring Web MVC), we'll adapt this signature. The HttpServletRequest object is the direct equivalent of ServerWebExchange, and we can return a simple String instead of a Mono<String>.

This leads us to a clean and simple interface definition for our project:

import jakarta.servlet.http.HttpServletRequest;

/**
 * A strategy interface for resolving a key from an incoming HTTP request.
 * This key is used to identify the client for rate limiting purposes.
 */
@FunctionalInterface
public interface RateLimitKeyResolver {

    /**
     * Resolves the key for rate limiting from the given HTTP request.
     *
     * @param request The incoming HTTP request.
     * @return The key as a String. Implementations should handle cases where a
     *         key cannot be resolved, typically by returning null or an empty string.
     */
    String resolve(HttpServletRequest request);
}

Note: I've added @FunctionalInterface because our interface has a single abstract method. This is a good practice in modern Java; it allows the interface to be implemented concisely using a lambda expression.



3. Implementing Concrete Key Resolver Strategies

With our interface defined, we can now create concrete strategies for the various identification methods your project requires.

Strategy 1: The IP Address Resolver

The most common strategy is to rate limit based on the client's IP address. A naive implementation might just call request.getRemoteAddr(). However, this fails in most production environments where your application runs behind a load balancer or a reverse proxy. In such cases, getRemoteAddr() will return the IP of the proxy, not the original client.

The standard solution is to inspect the X-Forwarded-For HTTP header.

Rate Limiting With Client IP in Spring Cloud Gateway

This article from Baeldung provides a fantastic, practical guide to implementing an IP-based key resolver, including the crucial detail of handling proxies.

Please read Section 4, 'Client’s IP Address in KeyResolver', and its subsection 4.1, 'Originating IP Address When Behind a Proxy'. Pay close attention to: The problem described when the application is behind a proxy. The solution using the X-Forwarded-For header to find the true originating IP.

Based on the logic described in the article, we can write our own IpAddressKeyResolver for Spring MVC:

public class IpAddressKeyResolver implements RateLimitKeyResolver {

    private static final String X_FORWARDED_FOR_HEADER = "X-Forwarded-For";

    @Override
    public String resolve(HttpServletRequest request) {
        String forwardedFor = request.getHeader(X_FORWARDED_FOR_HEADER);

        // If the X-Forwarded-For header is present, it may contain a comma-separated
        // list of IP addresses. The first one is the original client.
        if (forwardedFor != null && !forwardedFor.trim().isEmpty()) {
            return forwardedFor.split(",")[0].trim();
        }

        // If the header is not present, fall back to the remote address.
        return request.getRemoteAddr();
    }
}

This implementation is robust and suitable for a production environment.

Strategy 2: The API Key Resolver

Another common requirement is to limit clients based on a unique API key, usually passed in an HTTP header. The implementation for this is straightforward:

public class ApiKeyResolver implements RateLimitKeyResolver {

    private final String headerName;

    // The header name can be made configurable.
    public ApiKeyResolver(String headerName) {
        this.headerName = headerName;
    }

    @Override
    public String resolve(HttpServletRequest request) {
        return request.getHeader(this.headerName);
    }
}

This simple class demonstrates the power of our abstraction. By instantiating it with new ApiKeyResolver("X-Api-Key"), we have a resolver for that specific header.

Other Potential Strategies

The flexibility of this design allows for many other strategies, fulfilling your project's multi-tenancy requirement:

  • User ID Resolver: For authenticated users, you could retrieve the user's ID from Spring Security's SecurityContextHolder.
  • Endpoint Resolver: You could use request.getMethod() + "@" + request.getRequestURI() to create a key specific to an endpoint, allowing you to rate limit, for example, POST /api/orders differently from GET /api/products.
  • Composite Resolver: You could create a resolver that combines keys, such as userId + ":" + endpointPath, for fine-grained control. We will explore this in a later lesson.


4. How the Pieces Fit Together

Let's visualize how our two strategy interfaces, RateLimiter and RateLimitKeyResolver, will work together inside the Spring HandlerInterceptor we'll build later.

public class RateLimitingInterceptor implements HandlerInterceptor {

    // Our two strategy interfaces
    private final RateLimitKeyResolver keyResolver;
    private final RateLimiter rateLimiter;
    
    // Services to get the right rule and strategies for a request (to be built)
    private final RuleProvider ruleProvider; 
    private final StrategyProvider strategyProvider;

    // Dependencies are injected via constructor
    public RateLimitingInterceptor(...) { ... }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 1. Resolve the key using the KeyResolver strategy
        String key = keyResolver.resolve(request);
        
        if (key == null || key.isEmpty()) {
            // Policy decision: deny or allow if no key is found
            response.sendError(HttpStatus.FORBIDDEN.value(), "Could not identify client");
            return false;
        }

        // 2. Look up the rule and the correct algorithm for this key
        RateLimitRule rule = ruleProvider.getRule(key);
        RateLimiter algorithm = strategyProvider.get(rule.getAlgorithm());

        // 3. Use the RateLimiter strategy to check permission
        if (algorithm.allowRequest(key, rule)) {
            return true; // Request is allowed, proceed to the controller
        } else {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            // Optional: Add a 'Retry-After' header
            return false; // Request is denied
        }
    }
}

This conceptual code shows a clear separation of concerns:

  1. The RateLimitKeyResolver figures out who is calling.
  2. The RateLimiter figures out if they are allowed.
  3. The HandlerInterceptor orchestrates the process.

Key Takeaways

  • Client identification is a core challenge in building a flexible rate limiter.
  • The Strategy Design Pattern is the perfect tool to abstract away the logic of how a client is identified.
  • We designed a RateLimitKeyResolver interface with a single method: String resolve(HttpServletRequest request).
  • We outlined concrete implementations for common strategies like IP address (handling proxies) and API key resolution.
  • This design, combined with our RateLimiter interface from the previous lesson, forms the architectural foundation of our multi-tenant rate limiter.

Next Steps

We have now designed the two core abstractions for our rate limiter. In the next module, we will move from design to implementation. Our first step will be to implement the simplest algorithm, the in-memory fixed window counter, and integrate it into our Spring Boot application using the HandlerInterceptor and RateLimitKeyResolver we've just designed.