preparing for another refactoring...
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# flash-ext-limiter
|
||||
|
||||
Rate limiting for the Flash HTTP server. Zero-allocation hot-path, lock-free counters,
|
||||
pluggable key resolvers, and two built-in algorithms.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `@Limit` | Annotation for class-based handlers — processed once at boot |
|
||||
| `Guard` | Programmatic middleware factory for lambda routes |
|
||||
| `LimiterConfig` | Resolver registry — map string names to key-extraction lambdas |
|
||||
| `FIXED_WINDOW` | Clock-aligned counter reset; minimal memory |
|
||||
| `TOKEN_BUCKET` | Continuous refill; absorbs bursts smoothly |
|
||||
|
||||
## Dependency
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-limiter</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```java
|
||||
// Default install — only the built-in "ip" resolver available
|
||||
FlashApp.create(8080)
|
||||
.install(new LimiterExtension())
|
||||
.scan("com.example.handlers");
|
||||
```
|
||||
|
||||
```java
|
||||
// With custom resolvers
|
||||
LimiterConfig conf = new LimiterConfig()
|
||||
.registerResolver("auth_user", req ->
|
||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(new LimiterExtension(conf))
|
||||
.scan("com.example.handlers");
|
||||
```
|
||||
|
||||
## Installation order
|
||||
|
||||
Install `LimiterExtension` **before** authentication extensions. Rate-limit checks
|
||||
then short-circuit over-limit requests before expensive token validation runs.
|
||||
|
||||
```java
|
||||
app.install(new LimiterExtension(conf)) // ← first
|
||||
.install(new OidcExtension(oidcConf)) // ← second
|
||||
.scan("com.example");
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| [key-resolvers.md](key-resolvers.md) | Resolver registration, built-in defaults, custom logic |
|
||||
| [annotation.md](annotation.md) | `@Limit` reference — all fields and examples |
|
||||
| [guard.md](guard.md) | `Guard` for lambda routes — all overloads |
|
||||
| [strategies.md](strategies.md) | `FIXED_WINDOW` vs `TOKEN_BUCKET` — algorithm reference |
|
||||
| [http-headers.md](http-headers.md) | HTTP compliance — headers and 429 response |
|
||||
@@ -0,0 +1,132 @@
|
||||
# @Limit annotation
|
||||
|
||||
Applies a rate limit to a **class-based** `RequestHandler`. The annotation is read once
|
||||
per handler class at boot by the `LimiterExtension` annotation processor — zero overhead
|
||||
at request time.
|
||||
|
||||
## Declaration
|
||||
|
||||
```java
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface Limit {
|
||||
String key() default "ip";
|
||||
int requests();
|
||||
long window();
|
||||
TimeUnit windowUnit() default TimeUnit.SECONDS;
|
||||
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
|
||||
}
|
||||
```
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `key` | `String` | `"ip"` | Name of the key resolver registered in `LimiterConfig` |
|
||||
| `requests` | `int` | — | Maximum requests allowed per window (required) |
|
||||
| `window` | `long` | — | Window duration in `windowUnit` units (required) |
|
||||
| `windowUnit` | `TimeUnit` | `SECONDS` | Time unit for `window` |
|
||||
| `strategy` | `LimitStrategy` | `FIXED_WINDOW` | Rate-limit algorithm |
|
||||
|
||||
## Basic usage
|
||||
|
||||
### 100 requests per second per IP (default)
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.GET, path = "/api/search")
|
||||
@Limit(requests = 100, window = 1)
|
||||
public class SearchHandler extends RequestHandler {
|
||||
public Object handle(Request req, Response res) {
|
||||
return searchService.query(req.query("q"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 20 requests per minute per authenticated user
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.POST, path = "/api/report")
|
||||
@Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES)
|
||||
@Authenticated
|
||||
public class ReportHandler extends RequestHandler {
|
||||
public Object handle(Request req, Response res) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Order of annotation processors: register `LimiterExtension` before `OidcExtension`
|
||||
so the rate-limit middleware wraps the outer layer of the chain and fires before auth.
|
||||
|
||||
### Token bucket — absorb bursts
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.POST, path = "/api/upload")
|
||||
@Limit(
|
||||
key = "api_key",
|
||||
requests = 50,
|
||||
window = 1,
|
||||
windowUnit = TimeUnit.MINUTES,
|
||||
strategy = LimitStrategy.TOKEN_BUCKET
|
||||
)
|
||||
public class UploadHandler extends RequestHandler { ... }
|
||||
```
|
||||
|
||||
### Large window — 1000 requests per hour
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.GET, path = "/api/export")
|
||||
@Limit(requests = 1000, window = 1, windowUnit = TimeUnit.HOURS)
|
||||
public class ExportHandler extends RequestHandler { ... }
|
||||
```
|
||||
|
||||
### Strict per-second limit on a public endpoint
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.GET, path = "/api/prices")
|
||||
@Limit(requests = 10, window = 1, windowUnit = TimeUnit.SECONDS)
|
||||
public class PriceHandler extends RequestHandler { ... }
|
||||
```
|
||||
|
||||
## Combining @Limit with other annotations
|
||||
|
||||
`@Limit` composes naturally with `@Authenticated`, `@RolesAllowed`, and `@ApiOperation`.
|
||||
Each annotation is processed by its own processor; Flash collects all middleware and
|
||||
composes them in processor registration order.
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
|
||||
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
|
||||
@RolesAllowed("admin")
|
||||
@ApiOperation(summary = "Delete a user", tags = "admin")
|
||||
public class DeleteUserHandler extends RequestHandler { ... }
|
||||
```
|
||||
|
||||
Execution chain (outermost → handler):
|
||||
`LimiterMiddleware → OidcRolesMiddleware → DeleteUserHandler`
|
||||
|
||||
## Fail-fast at boot
|
||||
|
||||
If `key` names a resolver not registered in `LimiterConfig`, the server refuses to start:
|
||||
|
||||
```
|
||||
dev.relism.exceptions.InitializationException:
|
||||
Rate-limit resolver "auth_user" is not registered.
|
||||
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
|
||||
```
|
||||
|
||||
There is no silent fallback — a misconfigured rate limit is treated as a hard error.
|
||||
|
||||
## What happens on violation
|
||||
|
||||
```
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1711750860
|
||||
Retry-After: 1
|
||||
Content-Type: text/plain
|
||||
|
||||
Too Many Requests
|
||||
```
|
||||
|
||||
The handler body is never invoked. See [http-headers.md](http-headers.md) for the full
|
||||
header reference.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Guard — programmatic rate limiting for lambda routes
|
||||
|
||||
`Guard` is the rate-limit API for lambda (inline) route registrations. It produces
|
||||
a `Middleware` that is composed once at route-wiring time — the resolver lambda is
|
||||
captured directly into the closure, with no map lookup on the request hot-path.
|
||||
|
||||
## Obtaining Guard
|
||||
|
||||
`Guard` is provided in the `FlashContext` after `LimiterExtension` is installed:
|
||||
|
||||
```java
|
||||
Guard guard = app.ctx().require(Guard.class);
|
||||
```
|
||||
|
||||
Or inside another extension:
|
||||
|
||||
```java
|
||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
Guard guard = ctx.require(Guard.class);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```java
|
||||
// Fixed window (default strategy)
|
||||
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit)
|
||||
|
||||
// Explicit strategy
|
||||
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy)
|
||||
```
|
||||
|
||||
Both overloads:
|
||||
- Resolve the named key lambda **once** at call time (fail-fast if unknown).
|
||||
- Return a stateless `Middleware` whose closure captures the lambda and `LimitConfig` directly.
|
||||
- Share the `BucketStore` with all other rules registered through this `LimiterExtension` instance.
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple per-IP limit on a lambda route
|
||||
|
||||
```java
|
||||
Guard guard = app.ctx().require(Guard.class);
|
||||
|
||||
app.get("/api/search", (req, res) -> searchService.query(req.query("q")))
|
||||
.with(guard.limit("ip", 100, 1, TimeUnit.SECONDS));
|
||||
```
|
||||
|
||||
### Per authenticated user — token bucket
|
||||
|
||||
```java
|
||||
app.post("/api/export", (req, res) -> exportService.run(req))
|
||||
.with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
|
||||
```
|
||||
|
||||
### Chaining with other middleware
|
||||
|
||||
`Guard.limit(...)` returns a plain `Middleware`, so it composes with `Middleware.of()`
|
||||
and `.andThen()` exactly like any other middleware:
|
||||
|
||||
```java
|
||||
Middleware secured = Middleware.of(
|
||||
guard.limit("ip", 200, 1, TimeUnit.SECONDS), // ← outermost: runs first
|
||||
oidc.protect()
|
||||
);
|
||||
|
||||
app.get("/dashboard", handler).with(secured);
|
||||
```
|
||||
|
||||
Or with `.andThen()` for two middlewares:
|
||||
|
||||
```java
|
||||
app.get("/dashboard", handler)
|
||||
.with(guard.limit("ip", 200, 1, TimeUnit.SECONDS).andThen(oidc.protect()));
|
||||
```
|
||||
|
||||
### Different limits on the same path by method
|
||||
|
||||
```java
|
||||
// Read: 500/s; Write: 20/s
|
||||
app.get("/api/items", readHandler) .with(guard.limit("ip", 500, 1, TimeUnit.SECONDS));
|
||||
app.post("/api/items", writeHandler).with(guard.limit("ip", 20, 1, TimeUnit.SECONDS));
|
||||
```
|
||||
|
||||
Each `.with(guard.limit(...))` call creates an independent bucket store key namespace —
|
||||
GET and POST requests to `/api/items` share the same IP bucket only if you share the same
|
||||
`Middleware` instance. Using two `guard.limit(...)` calls creates **two independent buckets**.
|
||||
|
||||
### Reusing a middleware instance across routes
|
||||
|
||||
To share a single bucket pool across multiple routes (treating them as one combined limit):
|
||||
|
||||
```java
|
||||
Middleware sharedIpLimit = guard.limit("ip", 1000, 1, TimeUnit.MINUTES);
|
||||
|
||||
app.get("/api/items", handler1).with(sharedIpLimit);
|
||||
app.get("/api/items/{id}", handler2).with(sharedIpLimit);
|
||||
app.post("/api/items", handler3).with(sharedIpLimit);
|
||||
```
|
||||
|
||||
All three routes now draw from the same per-IP bucket — 1000 combined requests per minute.
|
||||
|
||||
### Inside an extension
|
||||
|
||||
```java
|
||||
public class MyApiExtension implements FlashExtension {
|
||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
Guard guard = ctx.require(Guard.class); // LimiterExtension must be installed first
|
||||
|
||||
Middleware ipLimit = guard.limit("ip", 60, 1, TimeUnit.SECONDS);
|
||||
|
||||
app.get("/api/status", statusHandler) .with(ipLimit);
|
||||
app.get("/api/metrics", metricsHandler).with(ipLimit);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Large window
|
||||
|
||||
```java
|
||||
app.get("/api/export", exportHandler)
|
||||
.with(guard.limit("api_key", 50, 24, TimeUnit.HOURS));
|
||||
```
|
||||
|
||||
## Fail-fast
|
||||
|
||||
If the resolver name is not registered, `guard.limit(...)` throws immediately
|
||||
(at wiring time, not at request time):
|
||||
|
||||
```
|
||||
InitializationException: Rate-limit resolver "auth_user" is not registered.
|
||||
```
|
||||
|
||||
## Comparison: Guard vs @Limit
|
||||
|
||||
| | `@Limit` | `Guard.limit(...)` |
|
||||
|---|---|---|
|
||||
| Route style | Class-based `RequestHandler` | Lambda `(req, res) -> ...` |
|
||||
| Configuration | Annotation fields | Method arguments |
|
||||
| Where resolved | `AnnotationProcessor` at `scan()` | `guard.limit(...)` call at wiring |
|
||||
| Hot-path overhead | Zero | Zero |
|
||||
| Fail-fast | Yes | Yes |
|
||||
| Composable with `Middleware.of()` | Via annotation processor order | Yes, directly |
|
||||
@@ -0,0 +1,122 @@
|
||||
# HTTP headers and 429 response
|
||||
|
||||
The extension injects standard rate-limit headers on **every** request — both allowed
|
||||
and rejected. Clients can use these headers to implement back-off logic without waiting
|
||||
for a 429.
|
||||
|
||||
## Response headers
|
||||
|
||||
| Header | Type | Description |
|
||||
|---|---|---|
|
||||
| `X-RateLimit-Limit` | integer | Maximum requests allowed in the current window |
|
||||
| `X-RateLimit-Remaining` | integer | Requests remaining in the current window (≥ 0) |
|
||||
| `X-RateLimit-Reset` | Unix timestamp (s) | When the quota resets or the next token arrives |
|
||||
| `Retry-After` | seconds | **Only on 429** — how long to wait before retrying (≥ 1) |
|
||||
|
||||
### Example — allowed request
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 73
|
||||
X-RateLimit-Reset: 1711750860
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Example — rejected request (429)
|
||||
|
||||
```
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1711750860
|
||||
Retry-After: 1
|
||||
Content-Type: text/plain
|
||||
|
||||
Too Many Requests
|
||||
```
|
||||
|
||||
## Header semantics by strategy
|
||||
|
||||
### FIXED_WINDOW
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `X-RateLimit-Reset` | Unix timestamp of the **next window start** (aligned to clock) |
|
||||
| `Retry-After` | Seconds until `X-RateLimit-Reset` (minimum 1) |
|
||||
|
||||
At a 1-second window boundary `Retry-After` will typically be `1`.
|
||||
|
||||
### TOKEN_BUCKET
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `X-RateLimit-Remaining` | Current token count (may increase between requests due to refill) |
|
||||
| `X-RateLimit-Reset` | Estimated Unix timestamp when the **next token arrives** |
|
||||
| `Retry-After` | Milliseconds-precise estimate converted to seconds (minimum 1) |
|
||||
|
||||
Because the token bucket refills continuously, `X-RateLimit-Reset` is a near-future
|
||||
timestamp rather than an aligned window boundary.
|
||||
|
||||
## Retry-After precision
|
||||
|
||||
`Retry-After` is computed as:
|
||||
|
||||
```
|
||||
retryAfter = max(1, X-RateLimit-Reset - currentTimeSeconds)
|
||||
```
|
||||
|
||||
The minimum value is always `1` second — RFC 7231 discourages `Retry-After: 0` as it
|
||||
encourages instant retry loops.
|
||||
|
||||
## Client-side back-off example (Java)
|
||||
|
||||
```java
|
||||
HttpResponse<String> res = client.send(request, BodyHandlers.ofString());
|
||||
|
||||
if (res.statusCode() == 429) {
|
||||
String retryAfter = res.headers().firstValue("Retry-After").orElse("1");
|
||||
long waitMs = Long.parseLong(retryAfter) * 1000L;
|
||||
Thread.sleep(waitMs);
|
||||
// retry...
|
||||
}
|
||||
```
|
||||
|
||||
## Client-side back-off example (JavaScript fetch)
|
||||
|
||||
```js
|
||||
const res = await fetch('/api/search?q=flash');
|
||||
|
||||
if (res.status === 429) {
|
||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '1', 10);
|
||||
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
||||
// retry...
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring / alerting
|
||||
|
||||
`X-RateLimit-Remaining` can be scraped by a metrics agent to track approaching limits
|
||||
before they hit 429:
|
||||
|
||||
- `remaining / limit < 0.1` → warning (less than 10% quota left)
|
||||
- `status == 429` → rate-limit violation counter increment
|
||||
|
||||
If `flash-ext-limiter` is used together with a future metrics extension, the 429 rate
|
||||
per resolver key is a natural signal for abuse detection or auto-scaling.
|
||||
|
||||
## Header injection timing
|
||||
|
||||
Headers are injected **before** calling `next.handle(req, res)` on allowed requests,
|
||||
and **instead of** calling it on rejected requests. This means:
|
||||
|
||||
- Handlers cannot accidentally overwrite `X-RateLimit-*` headers (they are set first,
|
||||
but handlers that call `res.header(...)` with the same name will add a second value —
|
||||
avoid this by not setting these headers manually).
|
||||
- On 429, the handler body is never executed — no side effects occur.
|
||||
|
||||
## Integration with Swagger UI (flash-ext-openapi)
|
||||
|
||||
Rate-limit headers are not currently injected into the OpenAPI spec. If you want to
|
||||
document them, add them manually via `@ApiOperation` on the handler class using the
|
||||
response headers section of the OpenAPI spec.
|
||||
@@ -0,0 +1,143 @@
|
||||
# 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 (OIDC / ClaimsHolder)
|
||||
|
||||
```java
|
||||
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
|
||||
|
||||
```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 ->
|
||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "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.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Rate-limit strategies
|
||||
|
||||
Two algorithms are built in. Both are lock-free (CAS-only), operate on pre-allocated
|
||||
`Bucket` state, and write results into a caller-supplied `long[2]` — zero per-request allocation.
|
||||
|
||||
## FIXED_WINDOW
|
||||
|
||||
```java
|
||||
@Limit(strategy = LimitStrategy.FIXED_WINDOW, ...) // default, can be omitted
|
||||
guard.limit("ip", 100, 1, TimeUnit.SECONDS) // default
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
The request counter resets to zero at each clock-aligned window boundary.
|
||||
|
||||
```
|
||||
window 1 window 2 window 3
|
||||
|────────────────|────────────────|────────────────|
|
||||
cnt: 0 1 2 … N cnt: 0 1 2 … N cnt: 0 1 2 … N
|
||||
```
|
||||
|
||||
With `requests = 100, window = 1s`:
|
||||
- Requests 1–100 in a given second → allowed
|
||||
- Request 101+ in that second → 429, allowed again at second +1
|
||||
|
||||
### Implementation
|
||||
|
||||
All state is packed into a single `AtomicLong` (`Bucket.slot0`):
|
||||
|
||||
```
|
||||
high 32 bits = reduced epoch = (currentTimeMs / windowMs) & 0xFFFFFFFF
|
||||
low 32 bits = request count in the current window
|
||||
```
|
||||
|
||||
One CAS operation per request. At a window boundary the same CAS atomically resets the
|
||||
counter to 1. No locks, no additional fields.
|
||||
|
||||
### Burst behaviour
|
||||
|
||||
Because the window is fixed to the clock, a burst can occur at the boundary:
|
||||
up to `N` requests at the end of window 1 followed immediately by `N` requests at the
|
||||
start of window 2 → `2N` requests in a short interval.
|
||||
|
||||
```
|
||||
window 1 │ window 2
|
||||
────────────┼────────────
|
||||
99 100 101 │ 1 2 3 4
|
||||
↑ reset: 101 → 429, then 1 is allowed
|
||||
```
|
||||
|
||||
If burst tolerance is unacceptable, use `TOKEN_BUCKET`.
|
||||
|
||||
### When to use
|
||||
|
||||
- Simple API rate limiting where occasional boundary bursts are acceptable.
|
||||
- Scenarios where a hard "N requests per clock second/minute" guarantee matters.
|
||||
- When you want minimal per-bucket memory (one `AtomicLong`, `Bucket.slot1` unused).
|
||||
|
||||
---
|
||||
|
||||
## TOKEN_BUCKET
|
||||
|
||||
```java
|
||||
@Limit(strategy = LimitStrategy.TOKEN_BUCKET, ...)
|
||||
guard.limit("ip", 100, 1, TimeUnit.SECONDS, LimitStrategy.TOKEN_BUCKET)
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
The bucket holds up to `requests` tokens and refills at a continuous rate of
|
||||
`requests / window` tokens per millisecond. Each request consumes one token.
|
||||
A client that was idle accumulates tokens and can fire a burst, but sustained
|
||||
excess traffic drains the bucket and triggers 429s.
|
||||
|
||||
```
|
||||
tokens
|
||||
N ─┐ ┌──── refill slope ────┐
|
||||
│ │ │
|
||||
0 └───────────┘ ←─ burst consumed ──→│
|
||||
burst here 429s during drain recovery
|
||||
```
|
||||
|
||||
### Refill rate
|
||||
|
||||
`refillPerMs = (requests × 1000) / windowMs` (integer, minimum 1)
|
||||
|
||||
For `requests = 100, window = 1s`:
|
||||
- Refill rate: 100 tokens/s = 1 token/10 ms
|
||||
- Max capacity: 100 tokens
|
||||
- A client idle for 500 ms accumulates 50 tokens and can fire 50 requests instantly.
|
||||
|
||||
### Implementation
|
||||
|
||||
- `Bucket.slot0` — current tokens × 1000 (fixed-point, avoids floating-point math)
|
||||
- `Bucket.slot1` — last-refill timestamp in ms (0 = uninitialised → bucket starts full)
|
||||
|
||||
One CAS loop on `slot0` per request; `slot1` updated best-effort after CAS success.
|
||||
The bounded inaccuracy from the non-atomic dual update is at most a few nanoseconds —
|
||||
negligible and self-correcting for rate limiting.
|
||||
|
||||
### Bucket starts full
|
||||
|
||||
On the very first request, `slot1 == 0`. The strategy treats this as "one full window
|
||||
elapsed" → `currentTokens = max`. The bucket starts at capacity; no warm-up needed.
|
||||
|
||||
### When to use
|
||||
|
||||
- APIs where clients legitimately batch requests (analytics, bulk imports).
|
||||
- Endpoints where smooth throughput matters more than hard per-second guarantees.
|
||||
- Any scenario where `FIXED_WINDOW` boundary bursts would be problematic.
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| | `FIXED_WINDOW` | `TOKEN_BUCKET` |
|
||||
|---|---|---|
|
||||
| Algorithm | Aligned counter reset | Continuous token refill |
|
||||
| Burst handling | Allows 2× limit at boundaries | Absorbs bursts up to bucket capacity |
|
||||
| Memory per bucket | 1 × `AtomicLong` used | 2 × `AtomicLong` used |
|
||||
| Clock alignment | Yes (predictable resets) | No (smooth) |
|
||||
| Typical use case | Simple request quotas | APIs with legitimate burst patterns |
|
||||
| CAS operations per request | 1 (usually) | 1 (usually) |
|
||||
|
||||
Both strategies use the same `Bucket` type. Both are lock-free and allocation-free after
|
||||
the bucket is first created.
|
||||
|
||||
---
|
||||
|
||||
## Adding a custom strategy
|
||||
|
||||
Implement `RateLimitStrategy` and wrap it in a `LimitStrategy` enum constant:
|
||||
|
||||
```java
|
||||
// 1. Implement the strategy
|
||||
public final class SlidingWindowStrategy implements RateLimitStrategy {
|
||||
@Override
|
||||
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
|
||||
// ... lock-free implementation using bucket.slot0 / slot1
|
||||
return allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add to the enum
|
||||
public enum LimitStrategy {
|
||||
FIXED_WINDOW { ... },
|
||||
TOKEN_BUCKET { ... },
|
||||
SLIDING_WINDOW {
|
||||
@Override
|
||||
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
|
||||
};
|
||||
public abstract RateLimitStrategy create();
|
||||
}
|
||||
```
|
||||
|
||||
The new strategy is immediately available to `@Limit(strategy = LimitStrategy.SLIDING_WINDOW)`
|
||||
and `guard.limit("ip", 100, 1, SECONDS, LimitStrategy.SLIDING_WINDOW)`.
|
||||
|
||||
### Strategy contract
|
||||
|
||||
```java
|
||||
public interface RateLimitStrategy {
|
||||
/**
|
||||
* @param bucket pre-allocated per-key state (never null)
|
||||
* @param cfg immutable rule config (limit, windowMs)
|
||||
* @param out out[0] = remaining, out[1] = reset epoch-seconds
|
||||
* @return true = allowed, false = rejected (429)
|
||||
*/
|
||||
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
|
||||
}
|
||||
```
|
||||
|
||||
Requirements for custom implementations:
|
||||
- **Lock-free** — use `AtomicLong.compareAndSet`; no `synchronized` or `ReentrantLock`.
|
||||
- **Stateless** — all mutable state must live in `Bucket.slot0` / `Bucket.slot1`.
|
||||
- **No allocation** — `out[]` is the only output channel; do not create objects on the hot path.
|
||||
- **Thread-safe** — called concurrently from many virtual threads.
|
||||
Reference in New Issue
Block a user