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',

Reply via email to