4.7 KiB
Key Resolvers
A key resolver is a lambda Request → String that extracts the partition key used
to identify who a rate limit applies to. Each unique key value gets its own independent
bucket — so "ip" limits per client address, "auth_user" limits per logged-in user, etc.
Built-in resolver: "ip"
Always present. Cannot be removed; can be overridden with registerResolver("ip", ...).
Resolution order:
X-Forwarded-Forheader — first address in the comma-separated list (client behind proxy)X-Real-IPheader — single forwarded IP (nginxproxy_set_header X-Real-IP)req.remoteAddress().getAddress().getHostAddress()— direct socket address, zero allocation (theInetSocketAddressalready exists fromServerSocket.accept(); onlygetHostAddress()allocates a String, and only when the first two headers are absent)"unknown"— only ifremoteAddress()is null (test-constructed requests)
// Override the built-in "ip" resolver to trust only the last hop in X-Forwarded-For
conf.registerResolver("ip", req -> {
String xff = req.header("X-Forwarded-For");
if (xff != null) {
String[] parts = xff.split(",");
return parts[parts.length - 1].strip(); // last = most recent proxy
}
return req.header("X-Real-IP") != null ? req.header("X-Real-IP").strip() : "unknown";
});
Registering custom resolvers
LimiterConfig conf = new LimiterConfig();
By authenticated user (OIDC / ClaimsHolder)
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
Requests from unauthenticated users share the "anonymous" bucket. If you want
unauthenticated requests to be unlimited, pair this resolver with @Limit only on
handlers that are already protected by @Authenticated.
By API key header
conf.registerResolver("api_key", req -> {
String key = req.header("X-Api-Key");
return key != null ? key : "none";
});
By tenant (multi-tenant SaaS)
conf.registerResolver("tenant", req -> {
// Extract from subdomain: acme.api.example.com → "acme"
String host = req.header("Host");
if (host == null) return "unknown";
int dot = host.indexOf('.');
return dot > 0 ? host.substring(0, dot) : host;
});
By IP + path (per-endpoint per-IP)
Combines two dimensions into a single key string:
conf.registerResolver("ip_path", req -> {
String ip = req.header("X-Forwarded-For");
if (ip == null) ip = "unknown";
int comma = ip.indexOf(',');
if (comma > 0) ip = ip.substring(0, comma).strip();
return ip + "|" + req.path();
});
Composite: role-based bucket size
One resolver, two different @Limit thresholds on two handler classes. The resolver
returns the same key for the same user regardless of endpoint; the limit is set per handler.
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
@Limit(key = "auth_user", requests = 1000, window = 1) // privileged endpoint
public class AdminReportHandler extends RequestHandler { ... }
@Limit(key = "auth_user", requests = 20, window = 1) // public endpoint
public class PublicSearchHandler extends RequestHandler { ... }
The two handlers maintain independent buckets for the same user — each @Limit
annotation gets its own BucketStore.
Resolver contract
@FunctionalInterface
public interface KeyResolver {
String resolve(Request req); // must never return null; return "unknown" as fallback
}
- Must not return
null— a null key will throwNullPointerExceptioninsideConcurrentHashMap. - Must be thread-safe — called concurrently from virtual threads.
- Should be fast — it runs on every request for every rate-limited route.
- No state should be mutated — treat
Requestas read-only.
Fail-fast validation
If a @Limit annotation or guard.limit(...) call references a resolver name that was never
registered, the server refuses to start with InitializationException:
InitializationException: Rate-limit resolver "auth_user" is not registered.
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
This check happens at boot time (annotation processor / Guard wiring), not at request time.
Registration API
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req -> ...)
.registerResolver("tenant", req -> ...)
.registerResolver("api_key", req -> ...);
app.install(new LimiterExtension(conf));
registerResolver returns this for fluent chaining. Calling it with an existing name
replaces the previous resolver — this is how you override the built-in "ip" resolver.