Skip to main content
Key resolution is the process of determining which Redis key (bucket) to use for rate limiting a particular request. The key determines what gets counted together.

Why key resolution matters

Different applications have different rate limiting requirements:

Global rate limiting

All requests to an endpoint share the same counter

Per-user rate limiting

Each user has their own counter

Per-IP rate limiting

Each IP address has its own counter

Per-tenant rate limiting

Each tenant/organization has its own counter
The RateLimitKeyResolver interface (key/RateLimitKeyResolver.java:11) allows you to implement any of these strategies.

Default key resolver

The library provides DefaultRateLimitKeyResolver (key/DefaultRateLimitKeyResolver.java:11), which generates keys from the annotation’s scope and the method signature.

Key format

The default resolver generates keys in this format:
{scope}:{className}#{methodName}
or if you specify a custom key in the annotation:
{scope}:{customKey}

Source code walkthrough

Here’s the complete implementation from key/DefaultRateLimitKeyResolver.java:14-24:
@Override
public String resolveKey(RateLimitContext context) {
    Objects.requireNonNull(context, "context must not be null");
    RateLimit annotation = Objects.requireNonNull(context.getAnnotation(), "annotation must not be null");

    String scope = normalizeScope(annotation.scope());
    if (annotation.key() != null && !annotation.key().isBlank()) {
        return scope + ":" + annotation.key().trim();
    }

    return scope + ":" + context.getTargetClass().getName() + "#" + context.getMethod().getName();
}
1

Extract annotation

Get the @RateLimit annotation from the context
2

Normalize scope

Convert the scope string to lowercase (e.g., “USER” → “user”, “IP” → “ip”)
3

Check for custom key

If the annotation has a non-blank key attribute, use it
4

Generate default key

Otherwise, combine the fully-qualified class name and method name

Scope normalization

The normalizeScope method (key/DefaultRateLimitKeyResolver.java:26-31) ensures consistent scope formatting:
private static String normalizeScope(String rawScope) {
    if (rawScope == null || rawScope.isBlank()) {
        return RateLimitScope.GLOBAL.getScope().toLowerCase();
    }
    return RateLimitScope.from(rawScope).getScope().toLowerCase();
}
If no scope is specified, it defaults to GLOBAL. The scope is always converted to lowercase for consistency.

Built-in scope types

The library defines three built-in scopes in RateLimitScope (model/RateLimitScope.java:3):
All requests share the same counter regardless of who makes them.Example key:
global:com.example.ApiController#getData
Use case: Public endpoints with absolute limits (e.g., “no more than 1000 requests/hour total”)

Real-world examples

Example 1: Global rate limiting

@RateLimit(limit = 100, duration = 60, scope = "global")
public List<Post> getRecentPosts() {
    return postService.getRecent();
}
Generated key:
global:com.example.PostController#getRecentPosts
All clients share the same 100 requests/minute limit.

Example 2: Custom key for shared limits

@RateLimit(limit = 50, duration = 60, key = "search-api")
public SearchResults searchPosts(@RequestParam String query) {
    return searchService.search(query);
}

@RateLimit(limit = 50, duration = 60, key = "search-api")
public SearchResults searchUsers(@RequestParam String query) {
    return userService.search(query);
}
Generated keys:
global:search-api  // Both methods share this key
Use custom keys to group multiple endpoints under a shared rate limit.

Example 3: Method-specific limits

public class ApiController {
    
    @RateLimit(limit = 100, duration = 60)
    public UserData getUser(@PathVariable String id) {
        return userService.getUser(id);
    }
    
    @RateLimit(limit = 10, duration = 60)
    public void updateUser(@PathVariable String id, @RequestBody UserData data) {
        userService.update(id, data);
    }
}
Generated keys:
global:com.example.ApiController#getUser
global:com.example.ApiController#updateUser
Each method has its own independent rate limit.

Custom key resolvers

For more advanced scenarios, you can implement your own RateLimitKeyResolver.

The RateLimitKeyResolver interface

The interface is simple (key/RateLimitKeyResolver.java:11-21):
public interface RateLimitKeyResolver {
    String resolveKey(RateLimitContext context);
}
The RateLimitContext provides (core/RateLimitContext.java:15):
getAnnotation()
RateLimit
The @RateLimit annotation with all its attributes (scope, limit, duration, key, etc.)
getTargetClass()
Class<?>
The class containing the annotated method
getMethod()
Method
The method being invoked
getArguments()
Object[]
The arguments passed to the method (useful for extracting IDs from parameters)
getTarget()
Object
The target instance (may be null for static methods)

Example: Per-user key resolver

Here’s a custom resolver that extracts the user ID from Spring Security:
import io.github.v4runsharma.ratelimiter.key.RateLimitKeyResolver;
import io.github.v4runsharma.ratelimiter.core.RateLimitContext;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

@Component
public class UserIdKeyResolver implements RateLimitKeyResolver {
    
    @Override
    public String resolveKey(RateLimitContext context) {
        String scope = context.getAnnotation().scope();
        if (scope == null || scope.isBlank()) {
            scope = "global";
        }
        
        // Extract user ID from Spring Security
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            return scope.toLowerCase() + ":anonymous";
        }
        
        String userId = auth.getName(); // or auth.getPrincipal()
        String methodKey = context.getTargetClass().getSimpleName() 
                         + "#" + context.getMethod().getName();
        
        return scope.toLowerCase() + ":user:" + userId + ":" + methodKey;
    }
}
Generated key example:
user:user:[email protected]:ApiController#getData
  • Extracts the authenticated user from Spring Security
  • Falls back to “anonymous” for unauthenticated requests
  • Combines scope, user ID, and method name
  • Each user gets their own independent rate limit

Example: Per-IP key resolver

import io.github.v4runsharma.ratelimiter.key.RateLimitKeyResolver;
import io.github.v4runsharma.ratelimiter.core.RateLimitContext;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Component
public class IpAddressKeyResolver implements RateLimitKeyResolver {
    
    @Override
    public String resolveKey(RateLimitContext context) {
        String scope = context.getAnnotation().scope().toLowerCase();
        String ipAddress = getClientIpAddress();
        String methodKey = context.getMethod().getName();
        
        return scope + ":ip:" + ipAddress + ":" + methodKey;
    }
    
    private String getClientIpAddress() {
        ServletRequestAttributes attrs = 
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        
        if (attrs == null) {
            return "unknown";
        }
        
        HttpServletRequest request = attrs.getRequest();
        
        // Check for X-Forwarded-For header (load balancer/proxy)
        String xff = request.getHeader("X-Forwarded-For");
        if (xff != null && !xff.isBlank()) {
            return xff.split(",")[0].trim();
        }
        
        // Fall back to remote address
        return request.getRemoteAddr();
    }
}
Generated key example:
ip:ip:192.168.1.100:getData
Be careful when using X-Forwarded-For headers, as they can be spoofed. Only trust them if you’re behind a trusted proxy/load balancer.

Example: Parameter-based key resolver

import io.github.v4runsharma.ratelimiter.key.RateLimitKeyResolver;
import io.github.v4runsharma.ratelimiter.core.RateLimitContext;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.PathVariable;
import java.lang.reflect.Parameter;

@Component
public class TenantIdKeyResolver implements RateLimitKeyResolver {
    
    @Override
    public String resolveKey(RateLimitContext context) {
        String scope = context.getAnnotation().scope().toLowerCase();
        String tenantId = extractTenantId(context);
        String methodKey = context.getMethod().getName();
        
        return scope + ":tenant:" + tenantId + ":" + methodKey;
    }
    
    private String extractTenantId(RateLimitContext context) {
        Parameter[] parameters = context.getMethod().getParameters();
        Object[] arguments = context.getArguments();
        
        // Look for a parameter named "tenantId" or annotated with @PathVariable("tenantId")
        for (int i = 0; i < parameters.length; i++) {
            Parameter param = parameters[i];
            if (param.getName().equals("tenantId")) {
                return String.valueOf(arguments[i]);
            }
        }
        
        return "default";
    }
}
Usage:
@RateLimit(limit = 100, duration = 60, keyResolver = TenantIdKeyResolver.class)
public Data getTenantData(@PathVariable String tenantId) {
    return dataService.getByTenant(tenantId);
}
Generated key example:
global:tenant:acme-corp:getTenantData

Registering custom resolvers

To use a custom key resolver, you have two options:

Option 1: Register as a Spring bean

Simply annotate your resolver with @Component or register it as a bean:
@Component
public class UserIdKeyResolver implements RateLimitKeyResolver {
    // ...
}
The auto-configuration will automatically detect and register it (support/DefaultRateLimitEnforcer.java:59-65).

Option 2: Specify in annotation

Reference your resolver class in the @RateLimit annotation:
@RateLimit(
    limit = 50, 
    duration = 60,
    keyResolver = UserIdKeyResolver.class  // Use custom resolver
)
public ApiResponse getData() {
    // ...
}
If you specify a keyResolver in the annotation, that resolver takes precedence over the default resolver for that specific method.

Best practices

Keep keys stable

The same request should always generate the same key. Avoid including timestamps or random values.

Keep keys short

Redis keys are stored in memory. Use abbreviations for common prefixes (e.g., “u:” instead of “user:”).

Include method context

Include the method or endpoint name in the key to prevent different operations from sharing limits unintentionally.

Validate extracted values

Always validate user IDs, IP addresses, etc. Fall back to a safe default (e.g., “anonymous”, “unknown”).
Test your key resolver by logging the generated keys during development. This helps catch issues before production.

Troubleshooting

Symptom: Users are sharing limits when they shouldn’t be (or vice versa).Solution: Log the generated keys and verify they’re unique per user/IP/tenant:
String key = keyResolver.resolveKey(context);
log.debug("Rate limit key: {}", key);
Symptom: Your custom resolver throws NPE.Solution: Always check for null values, especially:
  • context.getArguments() can be empty
  • Spring Security context might not be initialized
  • HTTP request might not be available in async/batch contexts
Symptom: The default resolver is still being used instead of your custom one.Solution: Ensure:
  1. Your resolver is annotated with @Component or registered as a bean
  2. You’ve specified keyResolver in the @RateLimit annotation
  3. Your resolver class is not abstract or interface

Next steps

Overview

Learn about the overall architecture

Fail-open vs fail-closed

Configure fault tolerance behavior

Build docs developers (and LLMs) love