This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch docs
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/docs by this push:
new b1fc332b98 docs: TODO-66 rate-limit + request-id topic page + 9.5
release notes + sidebar
b1fc332b98 is described below
commit b1fc332b9814781887861da8058a4983e5f607f2
Author: James Bognar <[email protected]>
AuthorDate: Sat May 23 09:54:58 2026 -0400
docs: TODO-66 rate-limit + request-id topic page + 9.5 release notes +
sidebar
Co-authored-by: Cursor <[email protected]>
---
pages/release-notes/9.5.0.md | 24 +++
.../10.20c.RestServerRateLimitAndRequestId.md | 235 +++++++++++++++++++++
sidebars.ts | 5 +
3 files changed, 264 insertions(+)
diff --git a/pages/release-notes/9.5.0.md b/pages/release-notes/9.5.0.md
index 36a707736a..749c678b8a 100644
--- a/pages/release-notes/9.5.0.md
+++ b/pages/release-notes/9.5.0.md
@@ -1986,6 +1986,30 @@ String name
### juneau-rest-server
+#### Rate-Limit Guard + Request-Id Filter (TODO-66)
+
+`juneau-rest-server` now ships two opt-in operational primitives — both purely
additive, both wired through existing `@Bean` / `@RestStartCall` extension
points. See [REST Server — Rate-Limiting and Request-Id
Propagation](/docs/topics/RestServerRateLimitAndRequestId) for the full
reference.
+
+- **`org.apache.juneau.rest.guard.RateLimitGuard`** — token-bucket `RestGuard`
with a fluent builder:
+ `permitsPerSecond(int)` / `permitsPerMinute(int)` / `permitsPerHour(int)`,
`burst(int)`,
+ `keyBy(Function<RestRequest,String>)` (default: client IP from
`req.getRemoteAddr()`),
+ `xForwardedForAware(boolean)` (default: `false` — only enable behind a
trusted proxy that re-writes the header),
+ `exemptPaths(String...)` (default: `/healthz`, `/readyz`, `/livez` to match
`BasicHealthResource`),
+ `whenLimitExceeded(BiConsumer<RestRequest,RateLimitInfo>)` (logging /
metrics hook),
+ `storage(Storage)` (default: in-memory with a 100k key cap and LRU eviction).
+- **Rejection shape.** `429 Too Many Requests` with `Retry-After` in seconds
(`Math.max(1, secondsUntilNextToken)`).
+- **Advisory headers.** Every passing request gets `X-RateLimit-Limit`,
`X-RateLimit-Remaining`, `X-RateLimit-Reset` (documented as advisory — no IETF
standard yet).
+- **`RateLimitGuard.Storage` SPI.** Pluggable backend for distributed
deployments; v1 ships only the in-memory default. The token-bucket math uses
`System.nanoTime()` deltas so it is immune to wall-clock drift.
+
+- **`org.apache.juneau.rest.filter.RequestIdFilter`** — `X-Request-Id` mint /
honor / echo filter:
+ `idSupplier(Supplier<String>)` (default: `UUID.randomUUID().toString()`),
+ `validator(Predicate<String>)` (default: `^[A-Za-z0-9-_]{1,128}$`),
+ `attributeKey(String)` (default: `RestServerConstants.REQUEST_ID`).
+- **Mint / honor / reject contract.** Absent → mint; present + valid → honor;
present + invalid → discard + mint fresh. The minted/honored id is stashed on
the request attributes and echoed on the response.
+- **Re-entrancy safe.** A second invocation on the same request short-circuits
on the existing attribute and re-echoes the same id.
+
+- **New constant.** `RestServerConstants.REQUEST_ID` (`"requestId"`) — the
canonical request-attribute key used by `RequestIdFilter`.
+
#### Health Probe SPI + Resource (TODO-65)
`juneau-rest-server` now includes a built-in probe SPI and aggregation
resource under
diff --git a/pages/topics/10.20c.RestServerRateLimitAndRequestId.md
b/pages/topics/10.20c.RestServerRateLimitAndRequestId.md
new file mode 100644
index 0000000000..38d1653be1
--- /dev/null
+++ b/pages/topics/10.20c.RestServerRateLimitAndRequestId.md
@@ -0,0 +1,235 @@
+---
+title: "Rate-Limiting and Request-Id Propagation"
+slug: RestServerRateLimitAndRequestId
+---
+
+Juneau REST servers ship two small, opt-in primitives for operational
hardening:
+
+- A token-bucket
[RestGuard](/site/apidocs/org/apache/juneau/rest/guard/RestGuard.html) —
`RateLimitGuard` — that throttles inbound traffic per key (default: client IP)
and answers with `429 Too Many Requests` + `Retry-After` when the bucket
empties.
+- A `@RestStartCall`-friendly filter — `RequestIdFilter` — that mints (or
honors) an `X-Request-Id` per request, stashes it on the request attributes,
and echoes it back on the response so downstream logs, traces, and clients can
correlate.
+
+Both classes are purely additive — they are wired in through the existing
`@Bean` / `@RestStartCall` extension points and do not change the behavior of
any pre-existing handler.
+
+## `RateLimitGuard` — token-bucket throttling
+
+<a href="/site/apidocs/org/apache/juneau/rest/guard/RateLimitGuard.html"
target="_blank">RateLimitGuard</a> is a `RestGuard` that runs before the
handler. It maintains a token bucket per key, refilled at a configurable
steady-state rate, and rejects requests when the bucket is empty.
+
+### Builder surface
+
+```java
+RateLimitGuard.create()
+ .permitsPerSecond(50) // or permitsPerMinute(N) /
permitsPerHour(N)
+ .burst(100) // maximum bucket capacity (default =
permitsPerSecond)
+ .keyBy(req -> req.getRemoteAddr()) // default: client IP from
req.getRemoteAddr()
+ .xForwardedForAware(true) // default: false; see warning below
+ .exemptPaths("/healthz","/readyz","/livez") // default: those three probe
paths
+ .whenLimitExceeded((req, info) -> log.warn("throttled key={} resetIn={}s",
info.key(), info.secondsUntilReset()))
+ .storage(RateLimitGuard.Storage.inMemory(100_000)) // default: in-memory,
100k key cap
+ .build();
+```
+
+| Builder method | Purpose
|
+|---------------------------------------------------------|------------------------------------------------------------------------------------------|
+| `permitsPerSecond(int)` / `permitsPerMinute(int)` / `permitsPerHour(int)` |
Steady-state refill rate. Pick one. |
+| `burst(int)` | Maximum bucket
capacity (cap on instantaneous burst). Defaults to the per-second rate. |
+| `keyBy(Function<RestRequest,String>)` | Key resolver.
Default: client IP from `req.getRemoteAddr()`. Returning `null` collapses
traffic onto a shared `"_"` bucket — useful for global throttling. |
+| `xForwardedForAware(boolean)` | When `true`,
prefer the first hop in `X-Forwarded-For` over `getRemoteAddr()`. See warning
below. |
+| `exemptPaths(String...)` | Paths that bypass
the guard entirely. Defaults to `/healthz`, `/readyz`, `/livez`. |
+| `whenLimitExceeded(BiConsumer<RestRequest,RateLimitInfo>)` | Optional
callback fired immediately before the `429` is thrown. Use for logging /
metrics. |
+| `storage(Storage)` | SPI for the bucket
store. Default: in-memory with a 100k key cap and LRU-style eviction. |
+
+### Wiring it into a resource
+
+Use a `@Bean RestGuardList` method to install the guard — the same mechanism
used for `RoleBasedRestGuard`:
+
+```java
+@Rest(path="/orders")
+public class OrderResource extends BasicRestServlet {
+
+ @Bean
+ public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(100)
+ .burst(200)
+ .build()
+ ).build();
+ }
+
+ @RestGet("/{id}")
+ public Order get(@Path long id) { ... }
+}
+```
+
+### Response on rejection
+
+When the bucket is empty the guard throws `429 Too Many Requests` with:
+
+```http
+HTTP/1.1 429 Too Many Requests
+Retry-After: 3
+X-RateLimit-Limit: 100
+X-RateLimit-Remaining: 0
+X-RateLimit-Reset: 3
+Content-Type: text/plain
+```
+
+`Retry-After` is in seconds and is always at least `1` (i.e. `Math.max(1,
secondsUntilNextToken)`).
+
+### Advisory `X-RateLimit-*` headers
+
+Every passing request gets three advisory response headers:
+
+| Header | Meaning
|
+|-------------------------|----------------------------------------------------------------------|
+| `X-RateLimit-Limit` | The configured burst capacity for this guard.
|
+| `X-RateLimit-Remaining` | Tokens remaining in the bucket after this request.
|
+| `X-RateLimit-Reset` | Seconds until the bucket is back at full capacity.
|
+
+These follow the de-facto convention popularized by GitHub, Twitter, and
Shopify. They are **advisory** — there is no IETF standard for them yet, and
the exact semantics differ slightly between providers. Clients should treat
them as hints, not contractual guarantees.
+
+### Token-bucket math
+
+The default in-memory storage tracks one `Bucket` per key. Each bucket holds a
`tokens` value (double) and a `lastNanos` timestamp from `System.nanoTime()`.
On every `tryAcquire`:
+
+1. Refill: `tokens = min(capacity, tokens + elapsedSeconds ×
permitsPerSecond)`.
+2. If `tokens >= 1` → decrement and allow.
+3. Otherwise reject and compute `secondsUntilNextToken = (1 - tokens) /
permitsPerSecond` (in seconds, ceiling).
+
+`System.nanoTime()` is monotonic but **not** wall-clock — buckets reset their
windows relative to monotonic deltas, which is correct for throttling and
immune to wall-clock drift.
+
+### Distributed-deploy footgun
+
+The default storage is **per-JVM**. A 4-pod deployment with
`permitsPerSecond(50)` effectively allows 200 RPS, not 50, because each pod has
its own bucket. Options:
+
+- Accept the per-pod multiplier (often fine for coarse protection).
+- Plug in a shared `Storage` backed by Redis, Memcached, or similar via
`.storage(Storage)`.
+- Push throttling up to an ingress (NGINX, Envoy, API Gateway) and use this
guard only as a defense in depth.
+
+The default in-memory storage caps at 100k keys and evicts the
least-recently-touched bucket when the cap is hit. This protects against
unbounded growth from random keys (e.g. spoofed IPs).
+
+### `X-Forwarded-For` spoofing warning
+
+When `xForwardedForAware(true)` is set, the guard uses the **first**
comma-separated hop in `X-Forwarded-For` as the rate-limit key. **This header
is trivially spoofed by any client**, so:
+
+- Only enable this flag when the server runs behind a trusted reverse proxy
(ELB, NGINX, Cloudflare, Envoy) that **strips and re-writes** `X-Forwarded-For`
from external traffic.
+- If the server is internet-facing without such a proxy, leave
`xForwardedForAware(false)` and rely on `getRemoteAddr()`.
+
+A spoofed header lets an attacker forge a fresh key per request and bypass the
bucket entirely.
+
+### Exempt paths
+
+`exemptPaths(...)` is intended for liveness / readiness / startup probes,
which:
+
+- Run on a tight schedule (often every 5–10 seconds) from a known set of IPs.
+- Must always succeed to avoid spurious pod-restart loops.
+
+The defaults — `/healthz`, `/readyz`, `/livez` — match the Juneau
`BasicHealthResource` paths. Override with your own list (or pass
`.exemptPaths()` for none) when paths differ.
+
+The path match is exact: it compares against `RestRequest.getPathInfo()`
first, then `getServletPath()`. Both are checked verbatim — there is no
prefix/glob matching in v1. Wildcard support is on the future-work list.
+
+### `RateLimitInfo` callback
+
+The optional `whenLimitExceeded(BiConsumer<RestRequest, RateLimitInfo>)`
callback fires immediately before the `429` is thrown. The `RateLimitInfo`
record carries:
+
+| Field | Meaning
|
+|------------------------|--------------------------------------------------------------------------|
+| `key()` | The resolved rate-limit key (IP, user id, custom).
|
+| `limit()` | The configured burst capacity.
|
+| `remaining()` | Tokens remaining (0 on rejection).
|
+| `secondsUntilReset()` | Seconds until the bucket is back at full capacity.
|
+| `allowed()` | `false` on rejection.
|
+
+Use this hook for metrics (`statsd`, `micrometer`), structured logging, or
pushing throttle events onto a queue for downstream analysis.
+
+## `RequestIdFilter` — `X-Request-Id` mint / honor / echo
+
+<a href="/site/apidocs/org/apache/juneau/rest/filter/RequestIdFilter.html"
target="_blank">RequestIdFilter</a> is a thin pre-call filter that:
+
+1. Honors a valid incoming `X-Request-Id` header.
+2. Mints a fresh one when the header is absent or fails validation.
+3. Stashes the resolved id on the request attribute keyed by
`RestServerConstants.REQUEST_ID`.
+4. Echoes the id on the response as `X-Request-Id`.
+
+### Builder surface
+
+```java
+RequestIdFilter.create()
+ .idSupplier(() -> UUID.randomUUID().toString()) // default
+ .validator(Pattern.compile("^[A-Za-z0-9-_]{1,128}$").asPredicate()) //
default
+ .attributeKey(RestServerConstants.REQUEST_ID) // default
+ .build();
+```
+
+| Builder method | Purpose
|
+|-----------------------------------------|--------------------------------------------------------------------------------|
+| `idSupplier(Supplier<String>)` | How fresh ids are minted. Default:
`UUID.randomUUID().toString()`. |
+| `validator(Predicate<String>)` | Whether to honor a given incoming
id. Default: `^[A-Za-z0-9-_]{1,128}$`. |
+| `attributeKey(String)` | Where to stash the id on the
request. Default: `RestServerConstants.REQUEST_ID`. |
+
+### Wiring it in
+
+The idiomatic shape is a single `@RestStartCall` method that delegates to the
filter:
+
+```java
+@Rest(path="/orders")
+public class OrderResource extends BasicRestServlet {
+
+ private static final RequestIdFilter REQUEST_ID =
RequestIdFilter.create().build();
+
+ @RestStartCall
+ public void onStart(HttpServletRequest req, HttpServletResponse res) {
+ REQUEST_ID.apply(req, res);
+ }
+
+ @RestGet("/{id}")
+ public Order get(@Path long id, RestRequest req) {
+ log.info("loading order id={} requestId={}", id,
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("?"));
+ ...
+ }
+}
+```
+
+### Mint / honor / reject
+
+| Incoming `X-Request-Id` | Behavior
|
+|---------------------------------|-----------------------------------------------------------------------------------|
+| absent | Mint via `idSupplier`, stash, echo on
response. |
+| present + matches `validator` | Honor as-is, stash, echo on response.
|
+| present + fails `validator` | Discard, mint a fresh one via
`idSupplier`, stash, echo on response. |
+
+Rejecting malformed ids is the safe default: an unvalidated header lands in
logs, metrics labels, and trace contexts. The default predicate
(`^[A-Za-z0-9-_]{1,128}$`) accepts UUIDs, ULIDs, `traceparent` ids, and most
opaque tokens while excluding whitespace, control characters, and
pathologically long values.
+
+### Re-entrancy
+
+If the filter is invoked twice on the same request (e.g. via a forward), the
second call sees the id already on the request attributes and short-circuits —
the previously minted/honored id is preserved and re-echoed. The attribute is
**not** overwritten by subsequent calls.
+
+## Interaction with mixins and paths
+
+Both primitives compose cleanly with the new `@Rest(mixins=...)` and
`@Rest(paths=...)` features (see [REST Server — Mixins and Multi-Mount
Paths](/docs/topics/RestServerCompositionMixinsAndPaths)):
+
+- `RateLimitGuard` installs through a `@Bean RestGuardList` method. When the
resource inherits a mixin that also contributes a guard list, the standard
`@Bean` resolution rules apply — the resource's local method overrides the
mixin's, so the mixin's guards are lost unless the resource composes them
explicitly. If both lists need to apply, append both inside the local method.
+- `RequestIdFilter` installs through a `@RestStartCall` method. Multiple
`@RestStartCall` methods on the same resource (including those inherited from
mixins) all run — Juneau collects them via reflection and invokes them in
declaration order. There is no implicit override, so mixin-contributed
`@RestStartCall` hooks coexist naturally with the request-id filter.
+
+## Scope notes
+
+The primitives cover the per-pod, in-process layer. Out of scope for v1:
+
+- **Distributed bucket sharing.** The `Storage` SPI exists explicitly to
support a future Redis-backed backend; v1 ships only the in-memory default.
+- **Wildcard / regex exempt paths.** Exact-match only.
+- **`Retry-After` HTTP-date form.** Always seconds.
+- **Trace context propagation.** `RequestIdFilter` only handles
`X-Request-Id`. W3C `traceparent` / `tracestate` and Zipkin `b3` headers are a
future work item.
+- **Per-operation throttle overrides.** All operations on a resource share the
same guard configuration. Pre-handler dispatch on annotation is a future work
item.
+
+## See also
+
+- [REST Server — Mixins and Multi-Mount
Paths](/docs/topics/RestServerCompositionMixinsAndPaths) — composing guard
lists and start-call hooks across mixins.
+- [Health Probes — Liveness / Readiness / Startup](/docs/topics/HealthProbes)
— the resource the default exempt paths target.
+- [Java Method Throwable Types](/docs/topics/JavaMethodThrowableTypes) — how
`TooManyRequests` lands on the wire.
+
+## Resources
+
+- [RFC 6585 §4 — 429 Too Many
Requests](https://www.rfc-editor.org/rfc/rfc6585#section-4)
+- [RFC 7231 §7.1.3 —
Retry-After](https://www.rfc-editor.org/rfc/rfc7231#section-7.1.3)
+- [W3C Trace Context — traceparent /
tracestate](https://www.w3.org/TR/trace-context/)
+- [Token Bucket Algorithm](https://en.wikipedia.org/wiki/Token_bucket)
diff --git a/sidebars.ts b/sidebars.ts
index 9c976e4a01..29d4f55422 100644
--- a/sidebars.ts
+++ b/sidebars.ts
@@ -1491,6 +1491,11 @@ const sidebars: SidebarsConfig = {
id:
'topics/10.20b.RestServerConditionalGet',
label: '10.20b.
Conditional-GET / ETag Helpers',
},
+ {
+ type: 'doc',
+ id:
'topics/10.20c.RestServerRateLimitAndRequestId',
+ label: '10.20c.
Rate-Limiting and Request-Id Propagation',
+ },
{
type: 'doc',
id:
'topics/10.21.BuiltInParameters',