preparing for another refactoring...

This commit is contained in:
Relism
2026-03-29 23:16:41 +02:00
parent 2edd68b0aa
commit b5d4481502
69 changed files with 4329 additions and 1076 deletions
@@ -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 1100 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);
}
@@ -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);
}
}
@@ -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;
}
@@ -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) {}
@@ -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();
}
@@ -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;
}
}
@@ -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);
};
}
}
@@ -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);
}
@@ -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.
}
}
}
@@ -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.
}
}
}