144 lines
4.8 KiB
Markdown
144 lines
4.8 KiB
Markdown
# 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:
|
|
1. `X-Forwarded-For` header — first address in the comma-separated list (client behind proxy)
|
|
2. `X-Real-IP` header — single forwarded IP (nginx `proxy_set_header X-Real-IP`)
|
|
3. `req.remoteAddress().getAddress().getHostAddress()` — direct socket address, zero allocation
|
|
(the `InetSocketAddress` already exists from `ServerSocket.accept()`; only `getHostAddress()`
|
|
allocates a String, and only when the first two headers are absent)
|
|
4. `"unknown"` — only if `remoteAddress()` is null (test-constructed requests)
|
|
|
|
```java
|
|
// 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
|
|
|
|
```java
|
|
LimiterConfig conf = new LimiterConfig();
|
|
```
|
|
|
|
### By authenticated user
|
|
|
|
```java
|
|
conf.registerResolver("auth_user", req ->
|
|
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "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
|
|
|
|
```java
|
|
conf.registerResolver("api_key", req -> {
|
|
String key = req.header("X-Api-Key");
|
|
return key != null ? key : "none";
|
|
});
|
|
```
|
|
|
|
### By tenant (multi-tenant SaaS)
|
|
|
|
```java
|
|
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:
|
|
|
|
```java
|
|
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.
|
|
|
|
```java
|
|
conf.registerResolver("auth_user", req ->
|
|
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon");
|
|
```
|
|
|
|
```java
|
|
@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
|
|
|
|
```java
|
|
@FunctionalInterface
|
|
public interface KeyResolver {
|
|
String resolve(Request req); // must never return null; return "unknown" as fallback
|
|
}
|
|
```
|
|
|
|
- Must not return `null` — a null key will throw `NullPointerException` inside `ConcurrentHashMap`.
|
|
- 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 `Request` as 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
|
|
|
|
```java
|
|
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.
|