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.
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-limiter</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Pre-allocated per-key rate limit state. Holds two {@link AtomicLong} slots whose
|
||||
* semantics are strategy-specific:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>FIXED_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
|
||||
* {@code slot1} unused.</li>
|
||||
* <li><b>TOKEN_BUCKET</b>: {@code slot0} = tokens × 1000 (scaled),
|
||||
* {@code slot1} = last-refill timestamp (ms since epoch).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Buckets are created once per unique key (via {@link BucketStore}) and reused
|
||||
* for the lifetime of the server — zero allocation on the warm path.
|
||||
*/
|
||||
public final class Bucket {
|
||||
public final AtomicLong slot0 = new AtomicLong(0L);
|
||||
public final AtomicLong slot1 = new AtomicLong(0L);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Thread-safe store of pre-allocated {@link Bucket} instances keyed by partition key.
|
||||
*
|
||||
* <p>On the warm path (key already seen), {@link #get} performs a single
|
||||
* {@link ConcurrentHashMap} lookup — no allocation. On the cold path (new key),
|
||||
* {@code computeIfAbsent} allocates exactly one {@link Bucket} and inserts it.
|
||||
*
|
||||
* <p>Buckets accumulate indefinitely; for workloads with unbounded unique keys
|
||||
* (e.g. one-shot crawlers), consider periodic store replacement or a bounded
|
||||
* LRU map implementation.
|
||||
*/
|
||||
public final class BucketStore {
|
||||
|
||||
private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Returns the bucket for {@code key}, creating one if absent.
|
||||
* Two threads racing on the same new key are guaranteed to receive the same bucket instance.
|
||||
*/
|
||||
public Bucket get(String key) {
|
||||
Bucket b = map.get(key);
|
||||
return b != null ? b : map.computeIfAbsent(key, k -> new Bucket());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Manual rate-limit guard for lambda routes.
|
||||
*
|
||||
* <p>Available via {@link dev.relism.extension.FlashContext}:
|
||||
* <pre>{@code
|
||||
* Guard guard = ctx.require(Guard.class);
|
||||
* }</pre>
|
||||
*
|
||||
* <p>{@link #limit} creates a {@link Middleware} that is composed once at route registration
|
||||
* time — the resolver lambda is captured directly from the registry (no runtime map lookup):
|
||||
* <pre>{@code
|
||||
* // 50 req/s per IP — fixed window (default)
|
||||
* app.get("/api/search", handler)
|
||||
* .with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
||||
*
|
||||
* // 10 req/min per authenticated user — token bucket
|
||||
* app.post("/api/export", handler)
|
||||
* .with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The resolver name is looked up once here (at {@code .with(guard.limit(...))} call time,
|
||||
* i.e. during app wiring, not on each request). If the name is not registered,
|
||||
* {@link dev.relism.exceptions.InitializationException} is thrown immediately.
|
||||
*/
|
||||
public final class Guard {
|
||||
|
||||
private final LimiterConfig config;
|
||||
private final BucketStore store;
|
||||
|
||||
Guard(LimiterConfig config, BucketStore store) {
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link Middleware} that enforces the given rate limit using
|
||||
* {@link LimitStrategy#FIXED_WINDOW}.
|
||||
*
|
||||
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
|
||||
* @param requests maximum requests allowed per window
|
||||
* @param window window duration in {@code unit}
|
||||
* @param unit time unit for {@code window}
|
||||
*/
|
||||
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit) {
|
||||
return limit(resolverKey, requests, window, unit, LimitStrategy.FIXED_WINDOW);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link Middleware} that enforces the given rate limit with the specified strategy.
|
||||
*
|
||||
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
|
||||
* @param requests maximum requests allowed per window
|
||||
* @param window window duration in {@code unit}
|
||||
* @param unit time unit for {@code window}
|
||||
* @param strategy rate-limit algorithm
|
||||
*/
|
||||
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy) {
|
||||
// Fail-fast: resolve the lambda at wiring time, not at request time.
|
||||
KeyResolver resolver = config.requireResolver(resolverKey);
|
||||
LimitConfig cfg = new LimitConfig(requests, unit.toMillis(window), strategy.create());
|
||||
return LimiterExtension.buildMiddleware(resolver, cfg, store);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
|
||||
/**
|
||||
* Extracts a partition key from an incoming request.
|
||||
*
|
||||
* <p>The resolved key identifies who the rate limit applies to — an IP address,
|
||||
* an authenticated user ID, an API key, etc. Implementations are captured once
|
||||
* at route registration time and called directly (no registry lookup) on every request.
|
||||
*
|
||||
* <pre>{@code
|
||||
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
|
||||
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
|
||||
* }</pre>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface KeyResolver {
|
||||
String resolve(Request req);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Applies a rate limit to a class-based {@link dev.relism.models.RequestHandler}.
|
||||
*
|
||||
* <p>The annotation is processed at boot time by the {@link LimiterExtension} annotation
|
||||
* processor. If {@link #key()} names a resolver that was never registered,
|
||||
* startup fails immediately with {@link dev.relism.exceptions.InitializationException}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // 100 req/s per client IP — fixed window
|
||||
* @Limit(requests = 100, window = 1)
|
||||
* public class SearchHandler extends RequestHandler { ... }
|
||||
*
|
||||
* // 20 req/min per authenticated user — token bucket
|
||||
* @Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES,
|
||||
* strategy = LimitStrategy.TOKEN_BUCKET)
|
||||
* public class ExpensiveHandler extends RequestHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface Limit {
|
||||
|
||||
/** Name of the resolver registered via {@link LimiterConfig#registerResolver}. Default: {@code "ip"}. */
|
||||
String key() default "ip";
|
||||
|
||||
/** Maximum number of requests allowed per {@link #window()}. */
|
||||
int requests();
|
||||
|
||||
/** Window duration in {@link #windowUnit()} units. */
|
||||
long window();
|
||||
|
||||
/** Unit for {@link #window()}. Default: {@link TimeUnit#SECONDS}. */
|
||||
TimeUnit windowUnit() default TimeUnit.SECONDS;
|
||||
|
||||
/** Rate-limit algorithm. Default: {@link LimitStrategy#FIXED_WINDOW}. */
|
||||
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
/**
|
||||
* Immutable configuration snapshot for a single rate-limit rule.
|
||||
* Created once at boot time and captured directly in the middleware closure.
|
||||
*
|
||||
* @param limit Maximum allowed requests per window.
|
||||
* @param windowMs Window duration in milliseconds.
|
||||
* @param strategy Strategy instance bound to this rule (one per rule, not shared).
|
||||
*/
|
||||
public record LimitConfig(int limit, long windowMs, RateLimitStrategy strategy) {}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
import dev.relism.ext.limiter.strategy.FixedWindowStrategy;
|
||||
import dev.relism.ext.limiter.strategy.TokenBucketStrategy;
|
||||
|
||||
/**
|
||||
* Enumeration of built-in rate-limit algorithms. Each constant is a factory
|
||||
* for its corresponding {@link RateLimitStrategy} implementation.
|
||||
*
|
||||
* <p>New algorithms can be added here without touching the rest of the extension.
|
||||
* The enum value is referenced by {@link Limit#strategy()} so user code refers
|
||||
* to the algorithm by name ({@code LimitStrategy.FIXED_WINDOW}) rather than
|
||||
* instantiating strategy objects directly.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Limit(key = "ip", requests = 100, window = 1, strategy = LimitStrategy.TOKEN_BUCKET)
|
||||
* public class SearchHandler extends RequestHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
public enum LimitStrategy {
|
||||
|
||||
/**
|
||||
* Fixed-window counter: resets to zero at each clock-aligned window boundary.
|
||||
* Simple, minimal memory, but allows up to 2× the limit in bursts that straddle
|
||||
* two windows.
|
||||
*/
|
||||
FIXED_WINDOW {
|
||||
@Override
|
||||
public RateLimitStrategy create() { return new FixedWindowStrategy(); }
|
||||
},
|
||||
|
||||
/**
|
||||
* Token-bucket: tokens refill continuously. Smooth burst absorption — a client
|
||||
* that was idle accumulates tokens and can fire a short burst, but sustained
|
||||
* excess traffic is rejected. Preferred for API endpoints where occasional bursts
|
||||
* are legitimate.
|
||||
*/
|
||||
TOKEN_BUCKET {
|
||||
@Override
|
||||
public RateLimitStrategy create() { return new TokenBucketStrategy(); }
|
||||
};
|
||||
|
||||
/** Creates a fresh, stateless {@link RateLimitStrategy} instance for this algorithm. */
|
||||
public abstract RateLimitStrategy create();
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
import dev.relism.exceptions.InitializationException;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Extension configuration: holds the named {@link KeyResolver} registry.
|
||||
*
|
||||
* <p>Resolvers are registered during the <em>config phase</em> (before {@code install}).
|
||||
* After {@link LimiterExtension#install} is called, the registry is consulted once per
|
||||
* route/handler at boot to capture the resolver lambda directly into the middleware closure.
|
||||
* There is no map lookup on the request hot-path.
|
||||
*
|
||||
* <p>The built-in {@code "ip"} resolver is always present and extracts the client IP from
|
||||
* {@code X-Forwarded-For} (first address) or {@code X-Real-IP}. Override it with
|
||||
* {@code registerResolver("ip", ...)} if needed.
|
||||
*
|
||||
* <pre>{@code
|
||||
* LimiterConfig conf = new LimiterConfig()
|
||||
* .registerResolver("auth_user", req -> {
|
||||
* // custom logic — e.g. extract sub from ClaimsHolder
|
||||
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
|
||||
* });
|
||||
*
|
||||
* app.install(new LimiterExtension(conf));
|
||||
* }</pre>
|
||||
*/
|
||||
public final class LimiterConfig {
|
||||
|
||||
private final Map<String, KeyResolver> resolvers = new LinkedHashMap<>();
|
||||
|
||||
public LimiterConfig() {
|
||||
// Built-in mandatory "ip" resolver.
|
||||
// Resolution order (standard reverse-proxy chain):
|
||||
// 1. X-Forwarded-For — first address (client behind one or more proxies)
|
||||
// 2. X-Real-IP — single forwarded IP (nginx proxy_set_header X-Real-IP)
|
||||
// 3. Socket address — direct connection, no proxy headers (zero alloc: the
|
||||
// InetSocketAddress already exists from accept(); only
|
||||
// getHostAddress() allocates a String, and only when reached)
|
||||
resolvers.put("ip", req -> {
|
||||
String xff = req.header("X-Forwarded-For");
|
||||
if (xff != null) {
|
||||
int comma = xff.indexOf(',');
|
||||
return comma > 0 ? xff.substring(0, comma).strip() : xff.strip();
|
||||
}
|
||||
String xri = req.header("X-Real-IP");
|
||||
if (xri != null) return xri.strip();
|
||||
InetSocketAddress addr = req.remoteAddress();
|
||||
return addr != null ? addr.getAddress().getHostAddress() : "unknown";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers (or replaces) a named key resolver. Returns {@code this} for fluent chaining.
|
||||
*
|
||||
* @param name identifier referenced by {@link Limit#key()} and {@link Guard#limit}
|
||||
* @param resolver lambda that extracts the partition key from a request
|
||||
*/
|
||||
public LimiterConfig registerResolver(String name, KeyResolver resolver) {
|
||||
if (name == null || name.isBlank()) throw new IllegalArgumentException("Resolver name must not be blank");
|
||||
if (resolver == null) throw new IllegalArgumentException("Resolver must not be null");
|
||||
resolvers.put(name, resolver);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolver for {@code name}.
|
||||
*
|
||||
* @throws InitializationException if no resolver with that name has been registered —
|
||||
* checked at boot time so misconfigurations surface immediately.
|
||||
*/
|
||||
KeyResolver requireResolver(String name) {
|
||||
KeyResolver r = resolvers.get(name);
|
||||
if (r == null) throw new InitializationException(
|
||||
"Rate-limit resolver \"" + name + "\" is not registered. " +
|
||||
"Call LimiterConfig.registerResolver(\"" + name + "\", req -> ...) before install.");
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Rate-limiting extension for Flash.
|
||||
*
|
||||
* <p>On {@link #install}:
|
||||
* <ol>
|
||||
* <li>Creates a single {@link BucketStore} shared by all rules in this extension instance.</li>
|
||||
* <li>Provides a {@link Guard} in the {@link FlashContext} for manual use on lambda routes.</li>
|
||||
* <li>Registers an {@link dev.relism.extension.AnnotationProcessor} for {@link Limit}:
|
||||
* reads the annotation once per handler class at boot, resolves the key lambda
|
||||
* from the registry <em>fail-fast</em>, then returns a pre-compiled middleware
|
||||
* that captures the lambda and config directly — zero map lookups at request time.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>Annotation-based (class handlers)</h3>
|
||||
* <pre>{@code
|
||||
* @Limit(requests = 100, window = 1) // 100 req/s per IP
|
||||
* public class SearchHandler extends RequestHandler { ... }
|
||||
*
|
||||
* @Limit(key = "auth_user", requests = 20, window = 1,
|
||||
* windowUnit = TimeUnit.MINUTES,
|
||||
* strategy = LimitStrategy.TOKEN_BUCKET)
|
||||
* public class ReportHandler extends RequestHandler { ... }
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Lambda routes (via Guard)</h3>
|
||||
* <pre>{@code
|
||||
* LimiterConfig conf = new LimiterConfig()
|
||||
* .registerResolver("auth_user", req -> ClaimsHolder.user().sub());
|
||||
*
|
||||
* app.install(new LimiterExtension(conf));
|
||||
*
|
||||
* Guard guard = app.ctx().require(Guard.class);
|
||||
* app.get("/api/search", handler).with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Installation order matters</h3>
|
||||
* Install {@code LimiterExtension} <em>before</em> authentication extensions so that
|
||||
* rate-limit checks short-circuit before expensive token validation on over-limit requests.
|
||||
*/
|
||||
public final class LimiterExtension implements FlashExtension {
|
||||
|
||||
private final LimiterConfig config;
|
||||
|
||||
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
||||
public LimiterExtension() {
|
||||
this(new LimiterConfig());
|
||||
}
|
||||
|
||||
/** Installs with a custom {@link LimiterConfig} (custom resolvers, etc.). */
|
||||
public LimiterExtension(LimiterConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
BucketStore store = new BucketStore();
|
||||
Guard guard = new Guard(config, store);
|
||||
|
||||
ctx.provide(Guard.class, guard);
|
||||
ctx.provide(LimiterConfig.class, config);
|
||||
|
||||
// Annotation processor: runs once per class-based handler at boot.
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
Limit ann = handlerClass.getAnnotation(Limit.class);
|
||||
if (ann == null) return List.of();
|
||||
|
||||
// Fail-fast: if the key is unknown the server refuses to start.
|
||||
KeyResolver resolver = config.requireResolver(ann.key());
|
||||
LimitConfig cfg = new LimitConfig(
|
||||
ann.requests(),
|
||||
ann.windowUnit().toMillis(ann.window()),
|
||||
ann.strategy().create()
|
||||
);
|
||||
|
||||
return List.of(buildMiddleware(resolver, cfg, store));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Package-private helper — shared with Guard ────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds the rate-limit {@link Middleware} from an already-resolved resolver lambda.
|
||||
*
|
||||
* <p>Hot-path design:
|
||||
* <ul>
|
||||
* <li>{@code resolver} is captured directly in the closure — no registry lookup per request.</li>
|
||||
* <li>{@code resultBuf} is a per-{@link Middleware}-instance ThreadLocal {@code long[2]}.
|
||||
* Allocated once per thread, reused forever — zero per-request allocation.</li>
|
||||
* <li>Header values ({@code String.valueOf(...)}) are the only unavoidable allocations;
|
||||
* they are tiny and bounded.</li>
|
||||
* </ul>
|
||||
*/
|
||||
static Middleware buildMiddleware(KeyResolver resolver, LimitConfig cfg, BucketStore store) {
|
||||
// One result buffer per thread, per middleware instance.
|
||||
// ThreadLocal is captured in the closure at boot time — not re-created per request.
|
||||
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
|
||||
|
||||
return next -> (req, res) -> {
|
||||
String key = resolver.resolve(req);
|
||||
Bucket bucket = store.get(key);
|
||||
long[] out = resultBuf.get();
|
||||
|
||||
boolean allowed = cfg.strategy().check(bucket, cfg, out);
|
||||
|
||||
// Always inject rate-limit headers — useful even on allowed requests.
|
||||
res.header("X-RateLimit-Limit", String.valueOf(cfg.limit()));
|
||||
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
|
||||
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
|
||||
|
||||
if (!allowed) {
|
||||
long retryAfter = Math.max(1L, out[1] - System.currentTimeMillis() / 1000L);
|
||||
res.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header("Retry-After", String.valueOf(retryAfter));
|
||||
return "Too Many Requests";
|
||||
}
|
||||
|
||||
return next.handle(req, res);
|
||||
};
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.relism.ext.limiter;
|
||||
|
||||
/**
|
||||
* Contract for a rate-limit algorithm. Implementations must be:
|
||||
* <ul>
|
||||
* <li><b>Lock-free</b> — rely only on {@link java.util.concurrent.atomic.AtomicLong} CAS operations.</li>
|
||||
* <li><b>Stateless</b> — all mutable state lives in the {@link Bucket}; the strategy itself
|
||||
* holds no instance fields so the same object can be shared across threads and rules.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Called on every request — must not allocate on the hot path.
|
||||
*
|
||||
* @see dev.relism.ext.limiter.strategy.FixedWindowStrategy
|
||||
* @see dev.relism.ext.limiter.strategy.TokenBucketStrategy
|
||||
*/
|
||||
public interface RateLimitStrategy {
|
||||
|
||||
/**
|
||||
* Checks whether this request is within the limit and updates the bucket atomically.
|
||||
*
|
||||
* <p>On return, {@code out} contains:
|
||||
* <ul>
|
||||
* <li>{@code out[0]} — remaining allowed requests in the current window (≥ 0).</li>
|
||||
* <li>{@code out[1]} — Unix epoch seconds at which the quota resets (for {@code X-RateLimit-Reset}
|
||||
* and {@code Retry-After} headers).</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param bucket per-key state carrier (pre-allocated, never null)
|
||||
* @param cfg immutable rule configuration
|
||||
* @param out caller-supplied two-element array; values are overwritten on every call
|
||||
* @return {@code true} if the request is within the limit and should proceed
|
||||
*/
|
||||
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.relism.ext.limiter.strategy;
|
||||
|
||||
import dev.relism.ext.limiter.Bucket;
|
||||
import dev.relism.ext.limiter.LimitConfig;
|
||||
import dev.relism.ext.limiter.RateLimitStrategy;
|
||||
|
||||
/**
|
||||
* Fixed-window rate limit: allows up to {@link LimitConfig#limit()} requests per window of
|
||||
* {@link LimitConfig#windowMs()} milliseconds. The window is aligned to clock time
|
||||
* (e.g. 10:00:00 – 10:00:59 for a 60-second window), not sliding.
|
||||
*
|
||||
* <h3>Implementation</h3>
|
||||
* The entire state fits in a single {@link java.util.concurrent.atomic.AtomicLong}
|
||||
* ({@link Bucket#slot0}), packed as:
|
||||
* <pre>
|
||||
* high 32 bits = reduced epoch (currentTimeMs / windowMs) & 0xFFFFFFFFL
|
||||
* low 32 bits = request count in the current window
|
||||
* </pre>
|
||||
* Each request performs a single CAS loop — no locks, no allocations.
|
||||
* At a window boundary the CAS atomically resets the counter to 1.
|
||||
*
|
||||
* <p>The reduced epoch wraps every {@code 2^32 × windowMs} milliseconds
|
||||
* (~13,000 years for a 100 ms window) — collision-free in practice.
|
||||
*/
|
||||
public final class FixedWindowStrategy implements RateLimitStrategy {
|
||||
|
||||
@Override
|
||||
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
|
||||
long now = System.currentTimeMillis();
|
||||
long absEpoch = now / cfg.windowMs();
|
||||
int epoch = (int)(absEpoch & 0xFFFFFFFFL); // reduced epoch, collision-safe
|
||||
|
||||
while (true) {
|
||||
long packed = bucket.slot0.get();
|
||||
int storedEpoch = (int)(packed >>> 32);
|
||||
int count = (int)(packed & 0xFFFFFFFFL);
|
||||
|
||||
// Same window: increment; new window: reset to 1.
|
||||
// Cap at limit+1 to guard against int overflow on extreme traffic.
|
||||
int newCount = (storedEpoch == epoch)
|
||||
? Math.min(count + 1, cfg.limit() + 1)
|
||||
: 1;
|
||||
|
||||
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
|
||||
|
||||
if (bucket.slot0.compareAndSet(packed, newPacked)) {
|
||||
out[0] = Math.max(0L, cfg.limit() - newCount);
|
||||
out[1] = (absEpoch + 1) * cfg.windowMs() / 1000L;
|
||||
return newCount <= cfg.limit();
|
||||
}
|
||||
// CAS lost — contention; re-read and retry.
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.relism.ext.limiter.strategy;
|
||||
|
||||
import dev.relism.ext.limiter.Bucket;
|
||||
import dev.relism.ext.limiter.LimitConfig;
|
||||
import dev.relism.ext.limiter.RateLimitStrategy;
|
||||
|
||||
/**
|
||||
* Token-bucket rate limit: tokens refill continuously at a rate of
|
||||
* {@code limit / windowMs} tokens per millisecond, up to a maximum of {@code limit} tokens.
|
||||
* Each request consumes one token. Burst traffic is absorbed until the bucket empties.
|
||||
*
|
||||
* <h3>Implementation</h3>
|
||||
* <ul>
|
||||
* <li>{@link Bucket#slot0} — current token count scaled by {@value #SCALE}
|
||||
* (allows sub-token precision without floating-point). Starts at 0; treated as
|
||||
* {@code maxScaled} when {@link Bucket#slot1} is 0 (first call → bucket starts full).</li>
|
||||
* <li>{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is updated best-effort after a
|
||||
* successful CAS. The resulting inaccuracy is bounded by the nanoseconds between the CAS
|
||||
* and the {@code set} — negligible and self-correcting for rate limiting purposes.
|
||||
*/
|
||||
public final class TokenBucketStrategy implements RateLimitStrategy {
|
||||
|
||||
/** Fixed-point scale factor. Stored tokens = actual tokens × SCALE. */
|
||||
static final long SCALE = 1_000L;
|
||||
|
||||
@Override
|
||||
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
|
||||
long now = System.currentTimeMillis();
|
||||
long maxScaled = (long) cfg.limit() * SCALE;
|
||||
// Refill rate: limit tokens per windowMs → (limit * SCALE) / windowMs scaled-tokens per ms.
|
||||
// Minimum 1 to ensure progress even for very large windows.
|
||||
long rfPerMs = Math.max(1L, maxScaled / cfg.windowMs());
|
||||
|
||||
while (true) {
|
||||
long lastMs = bucket.slot1.get();
|
||||
long rawTokens = bucket.slot0.get();
|
||||
|
||||
// When slot1 == 0 the bucket has never been used: treat as one full window elapsed
|
||||
// so the bucket starts completely full.
|
||||
long elapsed = (lastMs == 0L) ? cfg.windowMs() : Math.max(0L, now - lastMs);
|
||||
long currentTokens = Math.min(maxScaled, rawTokens + elapsed * rfPerMs);
|
||||
|
||||
if (currentTokens < SCALE) {
|
||||
// Not enough for one token — compute when the next token arrives.
|
||||
long needed = SCALE - currentTokens;
|
||||
long msToNext = (needed + rfPerMs - 1) / rfPerMs; // ceiling division
|
||||
out[0] = 0L;
|
||||
out[1] = (now + msToNext) / 1000L;
|
||||
// Best-effort: advance the refill baseline so the next call gets a fresh elapsed.
|
||||
bucket.slot0.compareAndSet(rawTokens, currentTokens);
|
||||
bucket.slot1.compareAndSet(lastMs, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
long newTokens = currentTokens - SCALE;
|
||||
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
|
||||
// Consumed successfully. Update refill baseline best-effort.
|
||||
bucket.slot1.set(now);
|
||||
out[0] = newTokens / SCALE;
|
||||
out[1] = now / 1000L;
|
||||
return true;
|
||||
}
|
||||
// CAS lost — another thread consumed a token concurrently; re-read and retry.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user