This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new d4bdb2c190 feat: TODO-66 RateLimitGuard + RequestIdFilter 
(juneau-rest-server)
d4bdb2c190 is described below

commit d4bdb2c190a6646fa67c874be2d70d05b9932c78
Author: James Bognar <[email protected]>
AuthorDate: Sat May 23 09:54:49 2026 -0400

    feat: TODO-66 RateLimitGuard + RequestIdFilter (juneau-rest-server)
---
 .../apache/juneau/rest/RestServerConstants.java    |  14 +
 .../apache/juneau/rest/filter/RequestIdFilter.java | 211 +++++++++
 .../apache/juneau/rest/guard/RateLimitGuard.java   | 527 +++++++++++++++++++++
 .../rest/filter/RequestIdFilter_Echo_Test.java     |  59 +++
 .../rest/filter/RequestIdFilter_Honor_Test.java    |  59 +++
 .../filter/RequestIdFilter_Malformed_Test.java     | 173 +++++++
 .../juneau/rest/filter/RequestIdFilter_Test.java   | 105 ++++
 .../guard/RateLimitGuard_AdvisoryHeaders_Test.java |  87 ++++
 .../rest/guard/RateLimitGuard_Eviction_Test.java   | 114 +++++
 .../guard/RateLimitGuard_ExemptPaths_Test.java     | 122 +++++
 .../guard/RateLimitGuard_KeyIsolation_Test.java    |  93 ++++
 .../juneau/rest/guard/RateLimitGuard_Test.java     | 192 ++++++++
 .../guard/RateLimitGuard_XForwardedFor_Test.java   |  99 ++++
 ...md => FINISHED-66-rate-limit-and-request-id.md} |   0
 todo/TODO-73-rest-paths-runtime-override.md        | 153 ++++++
 todo/TODO-74-mixin-api-docs.md                     | 137 ++++++
 todo/TODO.md                                       |   2 -
 17 files changed, 2145 insertions(+), 2 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
index 103cd9f84e..55fcd35a9b 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
@@ -212,4 +212,18 @@ public final class RestServerConstants {
 
        /** The {@code "value"} annotation attribute name — used by {@code 
@RestOp}/verb annotations to hold the (optional method-prefixed) path; folded 
into {@link #PROPERTY_path}. */
        public static final String PROPERTY_value = "value";
+
+       /**
+        * Servlet-request attribute key under which the per-request id minted 
or honored by
+        * {@code org.apache.juneau.rest.filter.RequestIdFilter} is stashed.
+        *
+        * <p>
+        * Call loggers and observability layers should resolve the id via this 
key so they all see the same value:
+        * <p class='bjava'>
+        *      String <jv>id</jv> = 
(String)<jv>req</jv>.getAttribute(RestServerConstants.<jsf>REQUEST_ID</jsf>);
+        * </p>
+        *
+        * @since 9.5.0
+        */
+       public static final String REQUEST_ID = "requestId";
 }
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/filter/RequestIdFilter.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/filter/RequestIdFilter.java
new file mode 100644
index 0000000000..1a404b601f
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/filter/RequestIdFilter.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.filter;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.*;
+import java.util.function.*;
+import java.util.regex.*;
+
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * Per-request id mint-or-honor filter that stashes the id on the servlet 
request and echoes it on the response.
+ *
+ * <p>
+ * Designed to be invoked from a {@link RestStartCall @RestStartCall} method 
so the id is available to every
+ * downstream component — the call logger, observability layers, and any 
application code that reads
+ * {@code req.getAttribute(RestServerConstants.REQUEST_ID)}.
+ *
+ * <h5 class='topic'>Behavior</h5>
+ *
+ * <ul>
+ *     <li>If the incoming request carries an {@code X-Request-Id} header and 
the value matches the configured
+ *             {@linkplain Builder#validator(Predicate) validator}, that value 
is honored.
+ *     <li>Otherwise (header absent, blank, or rejected by the validator), a 
fresh id is minted via the configured
+ *             {@linkplain Builder#idSupplier(Supplier) supplier} (default: 
{@link UUID#randomUUID()}).
+ *     <li>The chosen id is stashed on the underlying servlet request under the
+ *             {@link org.apache.juneau.rest.RestServerConstants#REQUEST_ID 
REQUEST_ID} attribute key.
+ *     <li>The chosen id is echoed on the response as {@code X-Request-Id}.
+ * </ul>
+ *
+ * <h5 class='topic'>Default validator</h5>
+ *
+ * <p>
+ * The default validator is {@code ^[A-Za-z0-9-_]{1,128}$}, which accepts 
UUIDs and the bulk of distributed-tracing
+ * id schemes (W3C Trace Context, OpenTelemetry, Datadog, etc.) while 
rejecting whitespace, control characters,
+ * header-injection payloads, and oversized strings.  Customize via {@link 
Builder#validator(Predicate)}.
+ *
+ * <h5 class='topic'>Example usage</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@Rest</ja>
+ *     <jk>public class</jk> ApiResource <jk>extends</jk> BasicRestServlet {
+ *
+ *             <jk>private static final</jk> RequestIdFilter 
<jsf>REQUEST_ID</jsf> = RequestIdFilter.<jsm>create</jsm>().build();
+ *
+ *             <ja>@RestStartCall</ja>
+ *             <jk>public void</jk> stampRequestId(HttpServletRequest 
<jv>req</jv>, HttpServletResponse <jv>res</jv>) {
+ *                     <jsf>REQUEST_ID</jsf>.apply(<jv>req</jv>, <jv>res</jv>);
+ *             }
+ *     }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerRateLimitAndRequestId";>REST
 Server — Rate-Limiting and Request-Id Propagation</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class RequestIdFilter {
+
+       /** Standard request and response header name for the request id. */
+       public static final String HEADER_REQUEST_ID = "X-Request-Id";
+
+       /** Default validator pattern.  Matches UUIDs and most 
distributed-tracing id schemes. */
+       public static final String DEFAULT_VALIDATOR_PATTERN = 
"^[A-Za-z0-9-_]{1,128}$";
+
+       private final Supplier<String> idSupplier;
+       private final Predicate<String> validator;
+       private final String attributeKey;
+
+       /**
+        * Constructor.
+        *
+        * @param b The builder configuring this filter.  Must not be 
<jk>null</jk>.
+        */
+       protected RequestIdFilter(Builder b) {
+               assertArgNotNull("builder", b);
+               this.idSupplier = b.idSupplier != null ? b.idSupplier : () -> 
UUID.randomUUID().toString();
+               this.validator = b.validator != null ? b.validator : 
Pattern.compile(DEFAULT_VALIDATOR_PATTERN).asPredicate();
+               this.attributeKey = b.attributeKey;
+       }
+
+       /**
+        * Creates a new builder.
+        *
+        * @return A new builder.
+        */
+       public static Builder create() {
+               return new Builder();
+       }
+
+       /**
+        * Mints or honors a request id, stashes it on the request, and echoes 
it on the response.
+        *
+        * <p>
+        * Idempotent: if the attribute is already set on the servlet request 
(for example by a parent filter), the
+        * existing value is honored and re-echoed.
+        *
+        * @param req The servlet request.  Must not be <jk>null</jk>.
+        * @param res The servlet response.  Must not be <jk>null</jk>.
+        * @return The resolved request id.  Never <jk>null</jk>.
+        */
+       public String apply(HttpServletRequest req, HttpServletResponse res) {
+               assertArgNotNull("req", req);
+               assertArgNotNull("res", res);
+               var existing = req.getAttribute(attributeKey);
+               if (existing instanceof String s && ! s.isEmpty()) {
+                       res.setHeader(HEADER_REQUEST_ID, s);
+                       return s;
+               }
+               var incoming = req.getHeader(HEADER_REQUEST_ID);
+               var id = (incoming != null && validator.test(incoming)) ? 
incoming : idSupplier.get();
+               req.setAttribute(attributeKey, id);
+               res.setHeader(HEADER_REQUEST_ID, id);
+               return id;
+       }
+
+       /**
+        * Builder for {@link RequestIdFilter}.
+        */
+       public static class Builder {
+
+               Supplier<String> idSupplier;
+               Predicate<String> validator;
+               String attributeKey = 
org.apache.juneau.rest.RestServerConstants.REQUEST_ID;
+
+               /**
+                * Constructor.
+                */
+               protected Builder() {}
+
+               /**
+                * Sets the supplier used to mint a new id when none is honored 
from the request.
+                *
+                * <p>
+                * Default is {@link UUID#randomUUID()}.  Swap in a smaller / 
shorter id scheme (e.g. a 16-byte
+                * base32 token) when payload size matters.
+                *
+                * @param value The supplier.  Must not be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder idSupplier(Supplier<String> value) {
+                       assertArgNotNull("value", value);
+                       idSupplier = value;
+                       return this;
+               }
+
+               /**
+                * Sets the predicate used to validate an incoming {@code 
X-Request-Id} header.
+                *
+                * <p>
+                * Default is {@code 
Pattern.compile("^[A-Za-z0-9-_]{1,128}$").asPredicate()} — accepts UUIDs and 
most
+                * distributed-tracing id schemes while rejecting whitespace, 
control characters, and oversize values.
+                * Values that fail validation are discarded and a fresh id is 
minted.
+                *
+                * @param value The predicate.  Must not be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder validator(Predicate<String> value) {
+                       assertArgNotNull("value", value);
+                       validator = value;
+                       return this;
+               }
+
+               /**
+                * Overrides the servlet-request attribute key under which the 
id is stashed.
+                *
+                * <p>
+                * Defaults to {@link 
org.apache.juneau.rest.RestServerConstants#REQUEST_ID}.  Override only when
+                * coexisting with a third-party filter that publishes the id 
under a different key.
+                *
+                * @param value The attribute key.  Must not be <jk>null</jk> 
or blank.
+                * @return This object.
+                */
+               public Builder attributeKey(String value) {
+                       assertArgNotNull("value", value);
+                       if (value.isBlank())
+                               throw new IllegalArgumentException("Argument 
'value' must not be blank.");
+                       attributeKey = value;
+                       return this;
+               }
+
+               /**
+                * Builds the filter.
+                *
+                * @return A new {@link RequestIdFilter}.
+                */
+               public RequestIdFilter build() {
+                       return new RequestIdFilter(this);
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/RateLimitGuard.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/RateLimitGuard.java
new file mode 100644
index 0000000000..7e4ca1b888
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/RateLimitGuard.java
@@ -0,0 +1,527 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.guard;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.function.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+
+/**
+ * Token-bucket {@link RestGuard} that throttles requests on a configurable 
per-request key.
+ *
+ * <p>
+ * On each request the guard atomically attempts to acquire one token from a 
per-key bucket.  When tokens are
+ * available the request proceeds and three advisory response headers are 
populated
+ * ({@code X-RateLimit-Limit}, {@code X-RateLimit-Remaining}, {@code 
X-RateLimit-Reset}).  When the bucket is
+ * empty a {@link TooManyRequests} exception is thrown with a {@code 
Retry-After} header set to the integer
+ * number of seconds until the next token will be available.
+ *
+ * <h5 class='topic'>Example usage</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@Rest</ja>(path=<js>"/api"</js>)
+ *     <jk>public class</jk> ApiResource <jk>extends</jk> BasicRestServlet {
+ *
+ *             <jc>// Per-IP rate limit, 100 req/min, 200-burst.</jc>
+ *             <ja>@Bean</ja>(name=<js>"guards"</js>)
+ *             <jk>public</jk> RestGuardList rateLimits(BeanStore <jv>bs</jv>) 
{
+ *                     <jk>return</jk> 
RestGuardList.<jsm>create</jsm>(<jv>bs</jv>)
+ *                             .append(RateLimitGuard.<jsm>create</jsm>()
+ *                                     .permitsPerMinute(100)
+ *                                     .burst(200)
+ *                                     .build())
+ *                             .build();
+ *             }
+ *     }
+ * </p>
+ *
+ * <h5 class='topic'>Keying</h5>
+ *
+ * <p>
+ * The default key is the request's {@linkplain RestRequest#getRemoteAddr() 
remote address}.  Set
+ * {@link Builder#xForwardedForAware(boolean) xForwardedForAware(true)} to 
honor the first hop of the
+ * {@code X-Forwarded-For} header — required when running behind a trusted 
reverse proxy.
+ *
+ * <p>
+ * <b>Trusted-proxy assumption.</b>  When {@code xForwardedForAware} is 
enabled without a trusted proxy in front
+ * of the application, attackers can spoof the {@code X-Forwarded-For} header 
and defeat the IP-based key.  Only
+ * opt in when the request edge enforces the header.
+ *
+ * <h5 class='topic'>Storage</h5>
+ *
+ * <p>
+ * Bucket state is held by a pluggable {@link Storage} SPI.  The default 
{@link Storage#inMemory()} implementation
+ * uses a {@link ConcurrentHashMap} with an LRU-style eviction policy (capped 
at 100 000 keys by default), suitable
+ * for single-pod deployments.  Distributed deployments should provide a 
substrate-backed implementation
+ * (e.g. Redis) so per-pod buckets do not split the shared rate envelope.
+ *
+ * <h5 class='topic'>Probe paths</h5>
+ *
+ * <p>
+ * Built-in probe paths ({@code /healthz}, {@code /readyz}, {@code /livez}) 
are exempted from throttling by
+ * default so a misconfigured limit cannot mask a healthy pod from the 
orchestrator.  Override via
+ * {@link Builder#exemptPaths(String...)}.
+ *
+ * <h5 class='topic'>Time source</h5>
+ *
+ * <p>
+ * Refill math uses {@link System#nanoTime()}.  This is monotonic and safe 
across wall-clock jumps but does not
+ * map to a calendar instant, which slightly complicates debugging when the 
bucket is in an unexpected state.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerRateLimitAndRequestId";>REST
 Server — Rate-Limiting and Request-Id Propagation</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class RateLimitGuard extends RestGuard {
+
+       /** Response header name for the advisory ceiling (tokens per refill 
window). */
+       public static final String HEADER_LIMIT = "X-RateLimit-Limit";
+
+       /** Response header name for the advisory tokens remaining after this 
call. */
+       public static final String HEADER_REMAINING = "X-RateLimit-Remaining";
+
+       /** Response header name for the advisory seconds until the bucket is 
fully refilled. */
+       public static final String HEADER_RESET = "X-RateLimit-Reset";
+
+       /** Standard HTTP {@code Retry-After} header name. */
+       public static final String HEADER_RETRY_AFTER = "Retry-After";
+
+       private final int capacity;
+       private final double permitsPerSecond;
+       private final Function<RestRequest,String> keyResolver;
+       private final boolean xForwardedForAware;
+       private final Set<String> exemptPaths;
+       private final BiConsumer<RestRequest,RateLimitInfo> onLimitExceeded;
+       private final Storage storage;
+
+       /**
+        * Constructor.
+        *
+        * @param b The builder configuring this guard.  Must not be 
<jk>null</jk>.
+        */
+       protected RateLimitGuard(Builder b) {
+               assertArgNotNull("builder", b);
+               if (b.permitsPerSecond <= 0.0)
+                       throw new IllegalArgumentException("Argument 
'permitsPerSecond' must be > 0.");
+               if (b.burst <= 0)
+                       throw new IllegalArgumentException("Argument 'burst' 
must be > 0.");
+               this.capacity = b.burst;
+               this.permitsPerSecond = b.permitsPerSecond;
+               this.keyResolver = b.keyResolver != null ? b.keyResolver : 
RestRequest::getRemoteAddr;
+               this.xForwardedForAware = b.xForwardedForAware;
+               this.exemptPaths = Set.copyOf(b.exemptPaths);
+               this.onLimitExceeded = b.onLimitExceeded;
+               this.storage = b.storage != null ? b.storage : 
Storage.inMemory();
+       }
+
+       /**
+        * Creates a new builder.
+        *
+        * @return A new builder.
+        */
+       public static Builder create() {
+               return new Builder();
+       }
+
+       @Override /* Overridden from RestGuard */
+       public boolean guard(RestRequest req, RestResponse res) {
+               if (isExempt(req))
+                       return true;
+               var key = resolveKey(req);
+               var result = storage.tryAcquire(key, capacity, 
permitsPerSecond);
+               var info = new RateLimitInfo(key, capacity, result.remaining(), 
result.secondsUntilReset(), result.allowed());
+               if (! result.allowed()) {
+                       setAdvisoryHeaders(res, info);
+                       var retry = Math.max(1L, info.secondsUntilReset());
+                       res.setHeader(HEADER_RETRY_AFTER, Long.toString(retry));
+                       if (onLimitExceeded != null)
+                               onLimitExceeded.accept(req, info);
+                       throw tooManyRequests(info);
+               }
+               setAdvisoryHeaders(res, info);
+               return true;
+       }
+
+       @Override /* Overridden from RestGuard */
+       public boolean isRequestAllowed(RestRequest req) {
+               return true;
+       }
+
+       private boolean isExempt(RestRequest req) {
+               if (exemptPaths.isEmpty())
+                       return false;
+               var pi = Objects.toString(req.getPathInfo(), "");
+               var sp = Objects.toString(req.getServletPath(), "");
+               return exemptPaths.contains(pi) || exemptPaths.contains(sp);
+       }
+
+       private String resolveKey(RestRequest req) {
+               if (xForwardedForAware) {
+                       var xff = req.getHeader("X-Forwarded-For");
+                       if (xff != null && ! xff.isBlank()) {
+                               var comma = xff.indexOf(',');
+                               return (comma < 0 ? xff : xff.substring(0, 
comma)).trim();
+                       }
+               }
+               var k = keyResolver.apply(req);
+               return k != null ? k : "";
+       }
+
+       private static TooManyRequests tooManyRequests(RateLimitInfo info) {
+               var retry = Math.max(1L, info.secondsUntilReset());
+               var ex = new TooManyRequests("Rate limit exceeded for key 
''{0}''. Retry after {1}s.", info.key(), retry);
+               ex.setHeader(HEADER_LIMIT, Integer.toString(info.limit()));
+               ex.setHeader(HEADER_REMAINING, "0");
+               ex.setHeader(HEADER_RESET, 
Long.toString(info.secondsUntilReset()));
+               ex.setHeader(HEADER_RETRY_AFTER, Long.toString(retry));
+               return ex;
+       }
+
+       private static void setAdvisoryHeaders(RestResponse res, RateLimitInfo 
info) {
+               res.setHeader(HEADER_LIMIT, Integer.toString(info.limit()));
+               res.setHeader(HEADER_REMAINING, 
Integer.toString(info.remaining()));
+               res.setHeader(HEADER_RESET, 
Long.toString(info.secondsUntilReset()));
+       }
+
+       /**
+        * Builder for {@link RateLimitGuard}.
+        */
+       public static class Builder {
+
+               private static final Set<String> DEFAULT_EXEMPT_PATHS = 
Set.of("/healthz", "/readyz", "/livez");
+
+               double permitsPerSecond = 10.0;
+               int burst = 10;
+               Function<RestRequest,String> keyResolver;
+               boolean xForwardedForAware;
+               Set<String> exemptPaths = new 
LinkedHashSet<>(DEFAULT_EXEMPT_PATHS);
+               BiConsumer<RestRequest,RateLimitInfo> onLimitExceeded;
+               Storage storage;
+
+               /**
+                * Constructor.
+                */
+               protected Builder() {}
+
+               /**
+                * Sets the steady-state refill rate in permits per second.
+                *
+                * @param value Permits per second.  Must be {@code > 0}.
+                * @return This object.
+                */
+               public Builder permitsPerSecond(int value) {
+                       permitsPerSecond = value;
+                       return this;
+               }
+
+               /**
+                * Sets the steady-state refill rate in permits per minute.
+                *
+                * <p>
+                * Equivalent to {@code permitsPerSecond(value / 60.0)}.
+                *
+                * @param value Permits per minute.  Must be {@code > 0}.
+                * @return This object.
+                */
+               public Builder permitsPerMinute(int value) {
+                       permitsPerSecond = value / 60.0;
+                       return this;
+               }
+
+               /**
+                * Sets the steady-state refill rate in permits per hour.
+                *
+                * <p>
+                * Equivalent to {@code permitsPerSecond(value / 3600.0)}.
+                *
+                * @param value Permits per hour.  Must be {@code > 0}.
+                * @return This object.
+                */
+               public Builder permitsPerHour(int value) {
+                       permitsPerSecond = value / 3600.0;
+                       return this;
+               }
+
+               /**
+                * Sets the burst capacity (maximum tokens a bucket can hold).
+                *
+                * @param value Burst capacity.  Must be {@code > 0}.
+                * @return This object.
+                */
+               public Builder burst(int value) {
+                       burst = value;
+                       return this;
+               }
+
+               /**
+                * Sets the per-request key resolver.
+                *
+                * <p>
+                * Default is {@link RestRequest#getRemoteAddr()}.  Common 
alternatives include the user-principal name,
+                * an API-key header, or a tenant id.  When the resolver 
returns <jk>null</jk> the empty string is used.
+                *
+                * @param value Resolver function.  Must not be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder keyBy(Function<RestRequest,String> value) {
+                       assertArgNotNull("value", value);
+                       keyResolver = value;
+                       return this;
+               }
+
+               /**
+                * Enables {@code X-Forwarded-For}-aware key resolution.
+                *
+                * <p>
+                * When enabled, the first comma-separated hop of the {@code 
X-Forwarded-For} header takes precedence over
+                * the configured {@link #keyBy(Function)} resolver.  Only opt 
in when a trusted reverse proxy strips and
+                * rewrites the header; otherwise clients can spoof it to 
defeat IP-based throttling.
+                *
+                * @param value <jk>true</jk> to honor {@code X-Forwarded-For}.
+                * @return This object.
+                */
+               public Builder xForwardedForAware(boolean value) {
+                       xForwardedForAware = value;
+                       return this;
+               }
+
+               /**
+                * Sets the set of request paths that bypass throttling.
+                *
+                * <p>
+                * Matched against {@link RestRequest#getPathInfo()} and {@link 
RestRequest#getServletPath()} (exact
+                * match).  Defaults to {@code /healthz}, {@code /readyz}, 
{@code /livez} — the built-in probe paths.
+                * Pass an empty array to clear the defaults.
+                *
+                * @param values Exempt paths.  Must not be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder exemptPaths(String...values) {
+                       assertArgNotNull("values", values);
+                       exemptPaths = new 
LinkedHashSet<>(Arrays.asList(values));
+                       return this;
+               }
+
+               /**
+                * Sets a callback invoked when a request is rejected due to an 
empty bucket.
+                *
+                * <p>
+                * Useful for structured logging or metrics emission.  The 
callback runs before {@link TooManyRequests} is
+                * thrown and must not block.  Exceptions raised inside the 
callback propagate to the caller.
+                *
+                * @param value Callback.  Must not be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder 
whenLimitExceeded(BiConsumer<RestRequest,RateLimitInfo> value) {
+                       assertArgNotNull("value", value);
+                       onLimitExceeded = value;
+                       return this;
+               }
+
+               /**
+                * Sets the bucket-state storage backend.
+                *
+                * <p>
+                * Default is {@link Storage#inMemory()}.  Substitute a 
distributed backend (Redis, DynamoDB, etc.) when
+                * running multiple pods that share a single rate envelope.
+                *
+                * @param value Storage backend.  Must not be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder storage(Storage value) {
+                       assertArgNotNull("value", value);
+                       storage = value;
+                       return this;
+               }
+
+               /**
+                * Builds the guard.
+                *
+                * @return A new {@link RateLimitGuard}.
+                */
+               public RateLimitGuard build() {
+                       return new RateLimitGuard(this);
+               }
+       }
+
+       /**
+        * Snapshot of rate-limit state passed to the {@link 
Builder#whenLimitExceeded(BiConsumer) onLimitExceeded}
+        * callback and used to populate advisory response headers.
+        *
+        * @param key The per-request key (e.g. remote address, principal name).
+        * @param limit The bucket capacity (also reported as {@code 
X-RateLimit-Limit}).
+        * @param remaining Tokens remaining after this acquisition attempt.  
Zero when the request was rejected.
+        * @param secondsUntilReset Seconds until the bucket is full again 
(also reported as {@code X-RateLimit-Reset}).
+        * @param allowed <jk>true</jk> if the request was admitted; 
<jk>false</jk> if the bucket was empty.
+        */
+       public record RateLimitInfo(String key, int limit, int remaining, long 
secondsUntilReset, boolean allowed) {}
+
+       /**
+        * SPI for storing per-key token-bucket state.
+        *
+        * <p>
+        * Implementations must be thread-safe.  The default {@link 
#inMemory()} implementation is suitable for
+        * single-pod deployments.  Multi-pod deployments that share a single 
rate envelope should substitute a
+        * distributed backend.
+        *
+        * @since 9.5.0
+        */
+       public interface Storage {
+
+               /**
+                * Attempts to acquire one token from the bucket associated 
with the given key.
+                *
+                * @param key The bucket key.  Never <jk>null</jk>.
+                * @param capacity The maximum tokens the bucket can hold.
+                * @param permitsPerSecond The steady-state refill rate.
+                * @return The outcome of the attempt.  Never <jk>null</jk>.
+                */
+               AcquireResult tryAcquire(String key, int capacity, double 
permitsPerSecond);
+
+               /**
+                * Evicts buckets idle for longer than the given TTL.
+                *
+                * <p>
+                * The default {@link #inMemory()} implementation evicts 
opportunistically when its size cap is exceeded;
+                * call this method to force an eager sweep (typically from a 
scheduled task).
+                *
+                * @param ttl The idle threshold.  Buckets last touched longer 
than {@code ttl} ago are removed.
+                */
+               void evict(Duration ttl);
+
+               /**
+                * Creates a new in-memory storage backend with the default 
size cap (100 000 keys).
+                *
+                * @return A new in-memory storage backend.
+                */
+               static Storage inMemory() {
+                       return new InMemoryStorage(100_000);
+               }
+
+               /**
+                * Creates a new in-memory storage backend with a custom size 
cap.
+                *
+                * @param maxKeys The maximum number of keys held before 
LRU-style eviction kicks in.  Must be {@code > 0}.
+                * @return A new in-memory storage backend.
+                */
+               static Storage inMemory(int maxKeys) {
+                       return new InMemoryStorage(maxKeys);
+               }
+
+               /**
+                * Outcome of a {@link #tryAcquire(String, int, double) 
tryAcquire} attempt.
+                *
+                * @param allowed <jk>true</jk> if the request was admitted.
+                * @param remaining Tokens left in the bucket after the attempt.
+                * @param secondsUntilReset Seconds until the bucket is full 
again.
+                */
+               record AcquireResult(boolean allowed, int remaining, long 
secondsUntilReset) {}
+       }
+
+       /**
+        * Default in-memory {@link Storage} implementation.
+        *
+        * <p>
+        * Backed by a {@link ConcurrentHashMap} keyed by the per-request key.  
Each bucket tracks token count and last
+        * touch time.  When the map exceeds {@code maxKeys} entries the 
least-recently-touched bucket is removed.
+        */
+       static final class InMemoryStorage implements Storage {
+
+               private final ConcurrentHashMap<String,Bucket> buckets = new 
ConcurrentHashMap<>();
+               private final int maxKeys;
+
+               InMemoryStorage(int maxKeys) {
+                       if (maxKeys <= 0)
+                               throw new IllegalArgumentException("Argument 
'maxKeys' must be > 0.");
+                       this.maxKeys = maxKeys;
+               }
+
+               @Override
+               public AcquireResult tryAcquire(String key, int capacity, 
double permitsPerSecond) {
+                       var bucket = buckets.computeIfAbsent(key, k -> new 
Bucket(capacity));
+                       var result = bucket.tryAcquire(capacity, 
permitsPerSecond);
+                       if (buckets.size() > maxKeys)
+                               evictOldest();
+                       return result;
+               }
+
+               @Override
+               public void evict(Duration ttl) {
+                       var threshold = System.nanoTime() - ttl.toNanos();
+                       buckets.entrySet().removeIf(e -> 
e.getValue().lastTouchedNanos() < threshold);
+               }
+
+               int size() {
+                       return buckets.size();
+               }
+
+               private void evictOldest() {
+                       buckets.entrySet().stream()
+                               .min(Comparator.comparingLong(e -> 
e.getValue().lastTouchedNanos()))
+                               .map(Map.Entry::getKey)
+                               .ifPresent(buckets::remove);
+               }
+       }
+
+       /**
+        * Per-key token-bucket state.
+        *
+        * <p>
+        * Holds a fractional token count and the {@link System#nanoTime()} 
value of the last touch.  The
+        * {@code synchronized} block keeps the read-refill-write sequence 
atomic without the overhead of a lock-free
+        * CAS loop on this hot path.
+        */
+       static final class Bucket {
+
+               private double tokens;
+               private long lastNanos;
+
+               Bucket(int capacity) {
+                       this.tokens = capacity;
+                       this.lastNanos = System.nanoTime();
+               }
+
+               synchronized Storage.AcquireResult tryAcquire(int capacity, 
double permitsPerSecond) {
+                       var now = System.nanoTime();
+                       var elapsedSeconds = (now - lastNanos) / 
1_000_000_000.0;
+                       tokens = Math.min(capacity, tokens + elapsedSeconds * 
permitsPerSecond);
+                       lastNanos = now;
+                       if (tokens >= 1.0) {
+                               tokens -= 1.0;
+                               return new Storage.AcquireResult(true, (int) 
Math.floor(tokens), secondsUntilFull(capacity, permitsPerSecond));
+                       }
+                       return new Storage.AcquireResult(false, 0, 
secondsUntilFull(capacity, permitsPerSecond));
+               }
+
+               synchronized long lastTouchedNanos() {
+                       return lastNanos;
+               }
+
+               private long secondsUntilFull(int capacity, double 
permitsPerSecond) {
+                       var needed = Math.max(0.0, capacity - tokens);
+                       return (long) Math.ceil(needed / permitsPerSecond);
+               }
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Echo_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Echo_Test.java
new file mode 100644
index 0000000000..76b30a891e
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Echo_Test.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+class RequestIdFilter_Echo_Test extends TestBase {
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create().build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/a")
+               public String a(RestRequest req) {
+                       return 
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("");
+               }
+       }
+
+       @Test void a01_responseHeaderMatchesRequestAttribute() throws Exception 
{
+               var c = 
MockRestClient.create(A.class).ignoreErrors().json().build();
+               var res = c.get("/a").run().assertStatus(200);
+               var echoed = 
res.getHeader("X-Request-Id").asString().orElseThrow();
+               res.assertContent().asString().isContains(echoed);
+       }
+
+       @Test void a02_eachRequestGetsIndependentId() throws Exception {
+               var c = 
MockRestClient.create(A.class).ignoreErrors().json().build();
+               var id1 = 
c.get("/a").run().assertStatus(200).getHeader("X-Request-Id").asString().orElseThrow();
+               var id2 = 
c.get("/a").run().assertStatus(200).getHeader("X-Request-Id").asString().orElseThrow();
+               assertNotEquals(id1, id2);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Honor_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Honor_Test.java
new file mode 100644
index 0000000000..3911e1901f
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Honor_Test.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.filter;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+class RequestIdFilter_Honor_Test extends TestBase {
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create().build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/a")
+               public String a(RestRequest req) {
+                       return 
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("");
+               }
+       }
+
+       @Test void a01_honorsValidIncomingId() throws Exception {
+               var c = 
MockRestClient.create(A.class).ignoreErrors().json().build();
+               c.get("/a").header("X-Request-Id", 
"550e8400-e29b-41d4-a716-446655440000").run()
+                       .assertStatus(200)
+                       
.assertHeader("X-Request-Id").is("550e8400-e29b-41d4-a716-446655440000")
+                       
.assertContent().asString().isContains("550e8400-e29b-41d4-a716-446655440000");
+       }
+
+       @Test void a02_honorsValidShortId() throws Exception {
+               var c = 
MockRestClient.create(A.class).ignoreErrors().json().build();
+               c.get("/a").header("X-Request-Id", "abc123_-").run()
+                       .assertStatus(200)
+                       .assertHeader("X-Request-Id").is("abc123_-")
+                       .assertContent().asString().isContains("abc123_-");
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Malformed_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Malformed_Test.java
new file mode 100644
index 0000000000..69ef991670
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Malformed_Test.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+class RequestIdFilter_Malformed_Test extends TestBase {
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create().build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/a")
+               public String a(RestRequest req) {
+                       return 
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("");
+               }
+       }
+
+       @Test void a01_malformedIdWithSpaceIsRejectedAndReminted() throws 
Exception {
+               var c = 
MockRestClient.create(A.class).ignoreErrors().json().build();
+               var res = c.get("/a").header("X-Request-Id", "abc 
xyz").run().assertStatus(200);
+               var echoed = 
res.getHeader("X-Request-Id").asString().orElseThrow();
+               assertNotEquals("abc xyz", echoed);
+               res.assertContent().asString().isContains(echoed);
+       }
+
+       @Test void a02_oversizeIdIsRejectedAndReminted() throws Exception {
+               var c = 
MockRestClient.create(A.class).ignoreErrors().json().build();
+               var oversize = "a".repeat(200);
+               var res = c.get("/a").header("X-Request-Id", 
oversize).run().assertStatus(200);
+               var echoed = 
res.getHeader("X-Request-Id").asString().orElseThrow();
+               assertNotEquals(oversize, echoed);
+       }
+
+       @Test void a03_emptyIdIsRejectedAndReminted() throws Exception {
+               var c = 
MockRestClient.create(A.class).ignoreErrors().json().build();
+               var res = c.get("/a").header("X-Request-Id", 
"").run().assertStatus(200);
+               var echoed = 
res.getHeader("X-Request-Id").asString().orElseThrow();
+               assertFalse(echoed.isEmpty());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Custom validator rejects everything → always mints.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class B extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create()
+                       .validator(s -> false)
+                       .idSupplier(() -> "always-minted")
+                       .build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/b")
+               public String b() { return "ok"; }
+       }
+
+       @Test void b01_customValidatorAlwaysRejectingAlwaysMints() throws 
Exception {
+               var c = MockRestClient.buildLax(B.class);
+               c.get("/b").header("X-Request-Id", 
"550e8400-e29b-41d4-a716-446655440000").run()
+                       .assertStatus(200)
+                       .assertHeader("X-Request-Id").is("always-minted");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Custom attribute key is honored and observed in re-entry (existing 
attribute is reused).
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class C extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create()
+                       .attributeKey("customReqId")
+                       .idSupplier(() -> "minted-once")
+                       .build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       FILTER.apply(req, res);
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/c")
+               public String c() { return "ok"; }
+       }
+
+       @Test void c01_reentryHonorsExistingAttribute() throws Exception {
+               var c = MockRestClient.buildLax(C.class);
+               c.get("/c").run()
+                       .assertStatus(200)
+                       .assertHeader("X-Request-Id").is("minted-once");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // An empty-string attribute already on the request is treated as 
missing and reminted.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class D extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create()
+                       .idSupplier(() -> "fresh-after-empty")
+                       .build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       req.setAttribute(RestServerConstants.REQUEST_ID, "");
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/d")
+               public String d() { return "ok"; }
+       }
+
+       @Test void d01_emptyStringAttributeRemints() throws Exception {
+               var c = MockRestClient.buildLax(D.class);
+               c.get("/d").run()
+                       .assertStatus(200)
+                       .assertHeader("X-Request-Id").is("fresh-after-empty");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // A non-String attribute already on the request is treated as missing 
and reminted.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class E extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create()
+                       .idSupplier(() -> "fresh-after-non-string")
+                       .build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       req.setAttribute(RestServerConstants.REQUEST_ID, 
Integer.valueOf(42));
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/e")
+               public String e() { return "ok"; }
+       }
+
+       @Test void e01_nonStringAttributeRemints() throws Exception {
+               var c = MockRestClient.buildLax(E.class);
+               c.get("/e").run()
+                       .assertStatus(200)
+                       
.assertHeader("X-Request-Id").is("fresh-after-non-string");
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Test.java
new file mode 100644
index 0000000000..5ed8683133
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Test.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+class RequestIdFilter_Test extends TestBase {
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create().build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/a")
+               public String a(RestRequest req) {
+                       return 
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("");
+               }
+       }
+
+       @Test void a01_mintsRequestIdWhenAbsent() throws Exception {
+               var c = MockRestClient.buildLax(A.class);
+               c.get("/a").run()
+                       .assertStatus(200)
+                       .assertHeader("X-Request-Id").isExists();
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Builder rejects null arguments.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_builderRejectsNullSupplier() {
+               assertThrows(IllegalArgumentException.class, () -> 
RequestIdFilter.create().idSupplier(null));
+       }
+
+       @Test void b02_builderRejectsNullValidator() {
+               assertThrows(IllegalArgumentException.class, () -> 
RequestIdFilter.create().validator(null));
+       }
+
+       @Test void b03_builderRejectsNullAttributeKey() {
+               assertThrows(IllegalArgumentException.class, () -> 
RequestIdFilter.create().attributeKey(null));
+       }
+
+       @Test void b04_builderRejectsBlankAttributeKey() {
+               assertThrows(IllegalArgumentException.class, () -> 
RequestIdFilter.create().attributeKey("   "));
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // apply() rejects null arguments.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_applyRejectsNullRequest() {
+               assertThrows(IllegalArgumentException.class, () -> 
RequestIdFilter.create().build().apply(null, null));
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Custom supplier is honored when minting.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class D extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               private static final RequestIdFilter FILTER = 
RequestIdFilter.create()
+                       .idSupplier(() -> "fixed-id-123")
+                       .build();
+               @RestStartCall
+               public void stamp(HttpServletRequest req, HttpServletResponse 
res) {
+                       FILTER.apply(req, res);
+               }
+               @RestGet(path="/d")
+               public String d() { return "ok"; }
+       }
+
+       @Test void d01_customSupplierUsedToMintId() throws Exception {
+               var c = MockRestClient.buildLax(D.class);
+               c.get("/d").run()
+                       .assertStatus(200)
+                       .assertHeader("X-Request-Id").is("fixed-id-123");
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_AdvisoryHeaders_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_AdvisoryHeaders_Test.java
new file mode 100644
index 0000000000..0b4511ccd3
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_AdvisoryHeaders_Test.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.guard;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_AdvisoryHeaders_Test extends TestBase {
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(5)
+                                       .keyBy(req -> "static-headers-success")
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/a")
+               public String a() { return "ok"; }
+       }
+
+       @Test void a01_advisoryHeadersPopulatedOnSuccess() throws Exception {
+               var c = MockRestClient.buildLax(A.class);
+               c.get("/a").run()
+                       .assertStatus(200)
+                       .assertHeader("X-RateLimit-Limit").is("5")
+                       .assertHeader("X-RateLimit-Remaining").isExists()
+                       .assertHeader("X-RateLimit-Reset").isExists();
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Separate resource (and therefore separate RestContext + storage) for 
the rejection path
+       // so the per-class shared MockRestClient context cache doesn't leak 
state between tests.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class B extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(5)
+                                       .keyBy(req -> 
"static-headers-rejection")
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/b")
+               public String b() { return "ok"; }
+       }
+
+       @Test void b01_advisoryHeadersOnRejection() throws Exception {
+               var c = MockRestClient.buildLax(B.class);
+               for (var i = 0; i < 5; i++)
+                       c.get("/b").run().assertStatus(200);
+               c.get("/b").run()
+                       .assertStatus(429)
+                       .assertHeader("X-RateLimit-Limit").is("5")
+                       .assertHeader("X-RateLimit-Remaining").is("0")
+                       .assertHeader("X-RateLimit-Reset").isExists()
+                       .assertHeader("Retry-After").asInteger().isExists();
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Eviction_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Eviction_Test.java
new file mode 100644
index 0000000000..fc8ca3d58e
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Eviction_Test.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.guard;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_Eviction_Test extends TestBase {
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // LRU-style eviction kicks in once the size cap is exceeded.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_inMemoryStorageEvictsOnceCapExceeded() {
+               var s = new RateLimitGuard.InMemoryStorage(4);
+               for (var i = 0; i < 10; i++)
+                       s.tryAcquire("k-" + i, 1, 1.0);
+               assertTrue(s.size() <= 4, "expected size <= 4, was " + 
s.size());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // evict(ttl=0) sweeps every bucket older than zero nanoseconds — i.e. 
all of them.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a02_evictWithZeroTtlSweepsAll() throws InterruptedException {
+               var s = new RateLimitGuard.InMemoryStorage(100);
+               s.tryAcquire("a", 1, 1.0);
+               s.tryAcquire("b", 1, 1.0);
+               s.tryAcquire("c", 1, 1.0);
+               assertEquals(3, s.size());
+               Thread.sleep(5);
+               s.evict(Duration.ofNanos(1));
+               assertEquals(0, s.size());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Long TTL keeps buckets in place.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a03_evictWithLargeTtlKeepsBuckets() {
+               var s = new RateLimitGuard.InMemoryStorage(100);
+               s.tryAcquire("a", 1, 1.0);
+               s.evict(Duration.ofHours(1));
+               assertEquals(1, s.size());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Constructor rejects non-positive maxKeys.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a04_constructorRejectsNonPositiveMaxKeys() {
+               assertThrows(IllegalArgumentException.class, () -> new 
RateLimitGuard.InMemoryStorage(0));
+               assertThrows(IllegalArgumentException.class, () -> new 
RateLimitGuard.InMemoryStorage(-1));
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Storage.inMemory(int) factory wires the size cap correctly.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a05_storageInMemoryFactoryWithCapacity() {
+               var s = RateLimitGuard.Storage.inMemory(2);
+               assertNotNull(s);
+               s.tryAcquire("a", 1, 1.0);
+               s.tryAcquire("b", 1, 1.0);
+               s.tryAcquire("c", 1, 1.0);
+               s.tryAcquire("d", 1, 1.0);
+               s.evict(Duration.ofHours(1));
+               assertNotNull(s);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Custom storage backend can be wired via Builder.storage(...).
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a06_customStorageBackendIsWiredThroughBuilder() {
+               var custom = RateLimitGuard.Storage.inMemory(50);
+               var g = RateLimitGuard.create()
+                       .permitsPerSecond(1)
+                       .burst(1)
+                       .storage(custom)
+                       .build();
+               assertNotNull(g);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // secondsUntilFull is non-negative even when the bucket has refilled 
past nominal capacity.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a07_secondsUntilFullIsNonNegative() throws 
InterruptedException {
+               var s = new RateLimitGuard.InMemoryStorage(10);
+               s.tryAcquire("a", 5, 1000.0);
+               Thread.sleep(20);
+               var r = s.tryAcquire("a", 5, 1000.0);
+               assertTrue(r.secondsUntilReset() >= 0L);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_ExemptPaths_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_ExemptPaths_Test.java
new file mode 100644
index 0000000000..8bc3620a0e
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_ExemptPaths_Test.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.guard;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_ExemptPaths_Test extends TestBase {
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Default exempt paths (/healthz, /readyz, /livez) bypass throttling.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(1)
+                                       .keyBy(req -> "static-exempt")
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/healthz")
+               public String healthz() { return "ok"; }
+               @RestGet(path="/readyz")
+               public String readyz() { return "ok"; }
+               @RestGet(path="/livez")
+               public String livez() { return "ok"; }
+               @RestGet(path="/a")
+               public String a() { return "ok"; }
+       }
+
+       @Test void a01_defaultExemptPathsBypassThrottling() throws Exception {
+               var c = MockRestClient.buildLax(A.class);
+               for (var i = 0; i < 5; i++) {
+                       c.get("/healthz").run().assertStatus(200);
+                       c.get("/readyz").run().assertStatus(200);
+                       c.get("/livez").run().assertStatus(200);
+               }
+               c.get("/a").run().assertStatus(200);
+               c.get("/a").run().assertStatus(429);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Custom exempt paths replace the defaults.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class B extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(1)
+                                       .keyBy(req -> "static-custom-exempt")
+                                       .exemptPaths("/special")
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/special")
+               public String special() { return "ok"; }
+               @RestGet(path="/healthz")
+               public String healthz() { return "ok"; }
+       }
+
+       @Test void b01_customExemptPathsReplaceDefaults() throws Exception {
+               var c = MockRestClient.buildLax(B.class);
+               for (var i = 0; i < 3; i++)
+                       c.get("/special").run().assertStatus(200);
+               c.get("/healthz").run().assertStatus(200);
+               c.get("/healthz").run().assertStatus(429);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Empty exempt-paths list throttles even the probe paths.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class C extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(1)
+                                       .keyBy(req -> "static-no-exempt")
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/healthz")
+               public String healthz() { return "ok"; }
+       }
+
+       @Test void c01_emptyExemptPathsThrottlesEveryPath() throws Exception {
+               var c = MockRestClient.buildLax(C.class);
+               c.get("/healthz").run().assertStatus(200);
+               c.get("/healthz").run().assertStatus(429);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_KeyIsolation_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_KeyIsolation_Test.java
new file mode 100644
index 0000000000..6876489193
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_KeyIsolation_Test.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.guard;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_KeyIsolation_Test extends TestBase {
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(1)
+                                       .keyBy(req -> req.getHeader("X-Tenant"))
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/a")
+               public String a() { return "ok"; }
+       }
+
+       @Test void a01_differentKeysHaveIndependentBuckets() throws Exception {
+               var c = MockRestClient.buildLax(A.class);
+               c.get("/a").header("X-Tenant", "alpha").run().assertStatus(200);
+               c.get("/a").header("X-Tenant", "alpha").run().assertStatus(429);
+               c.get("/a").header("X-Tenant", "beta").run().assertStatus(200);
+               c.get("/a").header("X-Tenant", "beta").run().assertStatus(429);
+               c.get("/a").header("X-Tenant", "gamma").run().assertStatus(200);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Storage SPI directly: same key shares state, different keys are 
isolated.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_storageIsolatesKeys() {
+               var storage = RateLimitGuard.Storage.inMemory();
+               assertTrue(storage.tryAcquire("k1", 1, 1.0).allowed());
+               assertFalse(storage.tryAcquire("k1", 1, 1.0).allowed());
+               assertTrue(storage.tryAcquire("k2", 1, 1.0).allowed());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Null key from resolver is normalized to empty string.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class B extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(1)
+                                       .keyBy(req -> null)
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/b")
+               public String b() { return "ok"; }
+       }
+
+       @Test void b02_nullKeyNormalizedToEmptyStringSharedBucket() throws 
Exception {
+               var c = MockRestClient.buildLax(B.class);
+               c.get("/b").run().assertStatus(200);
+               c.get("/b").run().assertStatus(429);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Test.java
new file mode 100644
index 0000000000..0fcd3851bd
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Test.java
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.guard;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_Test extends TestBase {
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Burst drain + refill + 429 + Retry-After
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(3)
+                                       .keyBy(req -> "static-burst-drain")
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/a")
+               public String a() { return "ok"; }
+       }
+
+       @Test void a01_burstDrainsThenRejectsWith429AndRetryAfter() throws 
Exception {
+               var c = MockRestClient.buildLax(A.class);
+               c.get("/a").run().assertStatus(200);
+               c.get("/a").run().assertStatus(200);
+               c.get("/a").run().assertStatus(200);
+               c.get("/a").run()
+                       .assertStatus(429)
+                       .assertHeader("Retry-After").asInteger().isExists();
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Refill after wait
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class B extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(100)
+                                       .burst(1)
+                                       .keyBy(req -> "static")
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/b")
+               public String b() { return "ok"; }
+       }
+
+       @Test void b01_bucketRefillsAtConfiguredRate() throws Exception {
+               var c = MockRestClient.buildLax(B.class);
+               c.get("/b").run().assertStatus(200);
+               c.get("/b").run().assertStatus(429);
+               Thread.sleep(50);
+               c.get("/b").run().assertStatus(200);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Defaults reject zero/negative configuration
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_builderRejectsNonPositiveRate() {
+               assertThrows(IllegalArgumentException.class, () -> 
RateLimitGuard.create().permitsPerSecond(0).burst(1).build());
+       }
+
+       @Test void c02_builderRejectsNonPositiveBurst() {
+               assertThrows(IllegalArgumentException.class, () -> 
RateLimitGuard.create().permitsPerSecond(1).burst(0).build());
+       }
+
+       @Test void c03_builderRejectsNullKeyResolver() {
+               assertThrows(IllegalArgumentException.class, () -> 
RateLimitGuard.create().keyBy(null));
+       }
+
+       @Test void c04_builderRejectsNullStorage() {
+               assertThrows(IllegalArgumentException.class, () -> 
RateLimitGuard.create().storage(null));
+       }
+
+       @Test void c05_builderRejectsNullExemptPaths() {
+               assertThrows(IllegalArgumentException.class, () -> 
RateLimitGuard.create().exemptPaths((String[])null));
+       }
+
+       @Test void c06_builderRejectsNullOnLimitExceeded() {
+               assertThrows(IllegalArgumentException.class, () -> 
RateLimitGuard.create().whenLimitExceeded(null));
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // permitsPerMinute / permitsPerHour wiring
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_permitsPerMinuteAndHourSetRate() {
+               var g1 = 
RateLimitGuard.create().permitsPerMinute(60).burst(1).build();
+               var g2 = 
RateLimitGuard.create().permitsPerHour(3600).burst(1).build();
+               assertNotNull(g1);
+               assertNotNull(g2);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // onLimitExceeded callback fires on rejection
+       
//------------------------------------------------------------------------------------------------------------------
+
+       private static volatile boolean E_FIRED;
+
+       @Rest
+       public static class E extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(1)
+                                       .keyBy(req -> "static")
+                                       .exemptPaths()
+                                       .whenLimitExceeded((req, info) -> 
E_FIRED = true)
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/e")
+               public String e() { return "ok"; }
+       }
+
+       @Test void e01_onLimitExceededCallbackFiresOnRejection() throws 
Exception {
+               E_FIRED = false;
+               var c = MockRestClient.buildLax(E.class);
+               c.get("/e").run().assertStatus(200);
+               c.get("/e").run().assertStatus(429);
+               assertTrue(E_FIRED);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // TooManyRequests exception carries advisory headers + Retry-After
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void f01_tooManyRequestsCarriesAdvisoryHeadersAndRetryAfter() {
+               var storage = RateLimitGuard.Storage.inMemory();
+               assertTrue(storage.tryAcquire("k", 1, 1.0).allowed());
+               var result = storage.tryAcquire("k", 1, 1.0);
+               assertFalse(result.allowed());
+               assertEquals(0, result.remaining());
+               assertTrue(result.secondsUntilReset() >= 1L);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // isRequestAllowed always returns true (we override guard() instead)
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void g01_isRequestAllowedAlwaysTrue() {
+               var g = 
RateLimitGuard.create().permitsPerSecond(1).burst(1).build();
+               assertTrue(g.isRequestAllowed(null));
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Sanity: TooManyRequests exception type is reachable
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void h01_tooManyRequestsExceptionTypeAvailable() {
+               assertEquals(429, TooManyRequests.STATUS_CODE);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_XForwardedFor_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_XForwardedFor_Test.java
new file mode 100644
index 0000000000..a0a05a0c65
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_XForwardedFor_Test.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.guard;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the {@code xForwardedForAware} flag.
+ *
+ * <p>
+ * <b>Trusted-proxy assumption.</b>  These tests deliberately exercise the 
spoofable path to confirm the guard
+ * keys on the first {@code X-Forwarded-For} hop when opted in.  Production 
deployments must only enable the flag
+ * behind a reverse proxy that strips and rewrites the header.
+ */
+class RateLimitGuard_XForwardedFor_Test extends TestBase {
+
+       @Rest
+       public static class A extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(1)
+                                       .xForwardedForAware(true)
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/a")
+               public String a() { return "ok"; }
+       }
+
+       @Test void a01_keyResolvesFromFirstXForwardedForHop() throws Exception {
+               var c = MockRestClient.buildLax(A.class);
+               c.get("/a").header("X-Forwarded-For", "203.0.113.10, 
10.0.0.1").run().assertStatus(200);
+               c.get("/a").header("X-Forwarded-For", "203.0.113.10, 
10.0.0.2").run().assertStatus(429);
+               c.get("/a").header("X-Forwarded-For", 
"203.0.113.11").run().assertStatus(200);
+       }
+
+       @Test void a02_xForwardedForAbsentFallsBackToDefaultKey() throws 
Exception {
+               var c = MockRestClient.buildLax(A.class);
+               c.get("/a").remoteAddr("198.51.100.1").run().assertStatus(200);
+               c.get("/a").remoteAddr("198.51.100.1").run().assertStatus(429);
+               c.get("/a").remoteAddr("198.51.100.2").run().assertStatus(200);
+       }
+
+       @Test void a03_xForwardedForBlankFallsBackToDefaultKey() throws 
Exception {
+               var c = MockRestClient.buildLax(A.class);
+               c.get("/a").header("X-Forwarded-For", "   
").remoteAddr("198.51.100.5").run().assertStatus(200);
+               c.get("/a").header("X-Forwarded-For", "   
").remoteAddr("198.51.100.5").run().assertStatus(429);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Default behavior: X-Forwarded-For ignored when xForwardedForAware 
not enabled.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class B extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(
+                               RateLimitGuard.create()
+                                       .permitsPerSecond(1)
+                                       .burst(1)
+                                       .exemptPaths()
+                                       .build()
+                       ).build();
+               }
+               @RestGet(path="/b")
+               public String b() { return "ok"; }
+       }
+
+       @Test void b01_xForwardedForIgnoredWhenAwarenessOff() throws Exception {
+               var c = MockRestClient.buildLax(B.class);
+               
c.get("/b").remoteAddr("198.51.100.20").header("X-Forwarded-For", 
"203.0.113.99").run().assertStatus(200);
+               
c.get("/b").remoteAddr("198.51.100.20").header("X-Forwarded-For", 
"203.0.113.99").run().assertStatus(429);
+               
c.get("/b").remoteAddr("198.51.100.21").header("X-Forwarded-For", 
"203.0.113.99").run().assertStatus(200);
+       }
+}
diff --git a/todo/TODO-66-rate-limit-and-request-id.md 
b/todo/FINISHED-66-rate-limit-and-request-id.md
similarity index 100%
rename from todo/TODO-66-rate-limit-and-request-id.md
rename to todo/FINISHED-66-rate-limit-and-request-id.md
diff --git a/todo/TODO-73-rest-paths-runtime-override.md 
b/todo/TODO-73-rest-paths-runtime-override.md
new file mode 100644
index 0000000000..9f9afff7f3
--- /dev/null
+++ b/todo/TODO-73-rest-paths-runtime-override.md
@@ -0,0 +1,153 @@
+# TODO-73: Runtime-overridable `@Rest(paths=...)` resolution chain
+
+Source: split out of the post-FINISHED-72 mixin-pack planning on 2026-05-23. 
Foundational primitive for TODO-74 / 75 / 76 / 77 / 78.
+
+## Goal
+
+Make the URL patterns from `@Rest(paths=...)` runtime-overridable so 
applications can rewire mixin paths (e.g. moving `/healthz` to `/health/live` 
to match k8s conventions) without forking the mixin class. Mirrors how 
`@Rest(path="...")` is already overridable through 
`RestContext.Builder.path(String)`; this TODO extends the same affordance to 
the multi-mount `paths` array shipped in FINISHED-72.
+
+End-state developer experience:
+
+```java
+// 1. Annotation default — always present.
+@Rest(paths={"/healthz","/readyz","/livez"})
+public class BasicHealthResource extends BasicRestServlet { ... }
+
+// 2. Importer overrides via getter (subclass / mixin host).
+@Rest(mixins=BasicHealthResource.class, paths={"/api"})
+public class ApiResource extends BasicRestServlet {
+    @Override public String[] getPaths() { return new 
String[]{"/health/live","/health/ready"}; }
+}
+
+// 3. Importer overrides via config key (Juneau Config or Spring Environment).
+@Rest(mixins=BasicHealthResource.class, pathsKey="health.paths")
+public class ApiResource extends BasicRestServlet { ... }
+
+// 4. Programmatic override on the builder — wins over everything.
+@RestInit
+public void onInit(RestContext.Builder b) {
+    b.paths("/health/live","/health/ready");
+}
+```
+
+Documented precedence: **programmatic > config-key > getter > annotation 
default**.
+
+## Why now
+
+- Five sibling mixin TODOs (TODO-74 through TODO-78) each ship with a 
`@Rest(paths=...)` default. Without this primitive, every one of them would 
need to invent its own override pattern. Land this once, reuse everywhere.
+- `@Rest(path="...")` already supports a programmatic 
`RestContext.Builder.path(String)` setter, so half the precedence chain already 
exists for the scalar case — extending it to the array case is mostly a parity 
exercise.
+- Spring Boot users already expect `@Value("${health.paths}")` style 
externalization; FINISHED-72 currently forces them to subclass.
+- The change is additive — null/empty getter return falls back to the 
annotation, so existing FINISHED-72 callers see no behavior change.
+
+## Scope
+
+**In scope (v1):**
+
+- `RestContext.Builder.paths(String...)` programmatic setter (parallel to 
existing `path(String)` setter).
+- `RestObject#getPaths()` / `RestServlet#getPaths()` virtual method on the 
canonical resource base classes — default implementation returns `null` (= 
"inherit annotation"). Subclasses can override to substitute paths at 
construction time.
+- `@Rest(pathsKey="...")` annotation member — when set, the framework reads 
`Config.getString(pathsKey)` (and, under the Spring-Boot-`BeanStore` adapter, 
`Environment.getProperty(pathsKey)`) and parses the comma-delimited result as a 
`String[]`. Empty / unset key falls through to the next rung.
+- Resolution occurs once during `RestContext` construction; the resolved 
`String[]` is what `JettyServerComponent.restPathsFor(...)` (and Spring Boot's 
`JuneauRestInitializer`) sees.
+- Documented precedence chain. Tests for each rung. Tests for the multi-mount 
integration through Jetty's multi-pattern mount.
+
+**Explicitly out of scope (v1):**
+
+- Hot-reload of paths after `RestContext` construction. One resolution pass; 
rebuild the context to re-resolve.
+- Per-request path rewriting. That's a different concern (URL rewriting, 
reverse proxy territory).
+- Wildcard expansion in `pathsKey` — the resolved value is taken literally and 
split on commas, no glob support.
+- Migration of `@Rest(path="...")` (singular) to use this same chain. The 
singular case already has its own resolution; this TODO touches only the array 
variant.
+
+## Dependency-injection notes
+
+- **Mixin/resource resolution is unchanged.** The FINISHED-72 mixin walk and 
Spring `BeanStore` adapter 
(`juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/springboot/SpringBeanStore.java`)
 already resolve resource-class instances from the active bean store. No new 
plumbing is needed at the resource level — both microservice (`BasicBeanStore` 
lookup) and Spring Boot (`SpringBeanStore` → `ApplicationContext.getBean(...)`) 
paths produce a `RestContext.Builder`  [...]
+- **Builder-time configuration sourcing.** The four override rungs are sourced 
as follows under each path:
+    - **Programmatic setter** (`RestContext.Builder.paths(String...)`) — 
identical under both paths; called from `@RestInit` or a 
`RestServletInitializer` hook.
+    - **`getPaths()` getter** — identical under both paths; the importer 
subclass overrides the method.
+    - **`pathsKey` config-key resolution** — under microservice, looks up 
`BeanStore.getBean(Config.class)` → `Config.getString(pathsKey)`. Under Spring 
Boot, the `SpringBeanStore`'s parent-chain returns the same `Config` if one was 
registered, but the resolver also falls back to 
`BeanStore.getBean(org.springframework.core.env.Environment.class)` → 
`Environment.getProperty(pathsKey)` so Spring's standard property-resolution 
chain (`application.yml`, system props, `--args`, profiles, etc. [...]
+    - **Annotation default** — identical under both paths; pure compile-time 
literal.
+- **Spring-Boot-specific gotchas.**
+    - `Environment.getProperty(pathsKey)` returns a single `String`; we split 
on `,` (trimmed) to match `Config.getStringArray(...)` semantics. Document 
explicitly so users don't expect Spring's `String[]` array binding.
+    - `SpringBeanStore.getBean(Environment.class)` works only when the 
resource is constructed through the `JuneauRestInitializer` adapter (which 
seeds the bean store with the active `ApplicationContext`). Pure-microservice 
deployments harmlessly miss the `Environment` lookup and fall through to the 
next rung.
+    - `@Primary` / `@Qualifier` are not relevant for `Environment` (singleton) 
but are relevant if a user later registers a custom `Config` bean — the 
existing `BeanStore.getBean(Config.class)` returns the first match, which under 
Spring follows `@Primary` semantics. No new contract.
+- **Acceptance bullet** added below: "Mixin works identically when registered 
via Juneau `BeanStore` (microservice path) and via Spring `@Bean` (Spring Boot 
path); both paths covered by a test."
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. `RestContext.Builder` — confirm that `path(String)` already memoizes a 
single resolved value at build time and that we can add `paths(String...)` 
alongside without disturbing the singular flow.
+2. `JettyServerComponent.restPathsFor(...)` — confirm that the FINISHED-72 
multi-pattern mount reads from `RestContext.getPaths()` (or equivalent) and not 
directly from the annotation.
+3. `SpringBeanStore` — confirm that `BeanStore.getBean(Environment.class)` 
resolves cleanly when the adapter is in use and returns `Optional.empty()` 
otherwise.
+4. `Config.getString(...)` / `Config.getStringArray(...)` — confirm both are 
present and which signature gives us cleanest comma-split semantics.
+
+### Phase 1 — programmatic + getter rungs
+
+1. Add `RestContext.Builder.paths(String... paths)` setter; null/empty array 
clears.
+2. Add `RestServlet#getPaths()` and `RestObject#getPaths()` default-`null` 
virtual methods.
+3. Wire both into the path-resolution chain in `RestContext` build, with 
explicit precedence documented in the javadoc.
+4. Tests:
+    - `RestPathsRuntimeOverride_Programmatic_Test` — `Builder.paths(...)` 
overrides annotation; null arg resets to annotation default; empty arg 
explicitly clears (no mounts).
+    - `RestPathsRuntimeOverride_Getter_Test` — `getPaths()` override on 
subclass beats annotation; `null` return falls through.
+
+### Phase 2 — `pathsKey` config-key rung
+
+1. Add `String pathsKey() default ""` to `@Rest`.
+2. Resolution code: when `pathsKey` is non-empty, look up `Config` from the 
bean store and call `getStringArray(pathsKey)`; if result is empty, fall 
through.
+3. Tests:
+    - `RestPathsRuntimeOverride_ConfigKey_Test` — `pathsKey` resolves from a 
`Config` bean; missing key falls through; empty value falls through.
+
+### Phase 3 — Spring `Environment` fallback
+
+1. In the `pathsKey` resolver, after the `Config` miss, also try 
`BeanStore.getBean(Environment.class)` and split on `,`.
+2. Tests:
+    - `RestPathsRuntimeOverride_SpringEnvironment_Test` (in 
`juneau-rest/juneau-rest-server-springboot` test sources) — Spring 
`Environment.getProperty(...)` resolves the key when no Juneau `Config` is 
registered.
+
+### Phase 4 — multi-mount integration
+
+1. Confirm `JettyServerComponent` and the Spring Boot `JuneauRestInitializer` 
both consume the resolved `String[]` (not the raw annotation).
+2. Tests:
+    - `RestPathsRuntimeOverride_JettyMount_Test` — programmatic override 
produces correct exact-match mounts under Jetty.
+    - `RestPathsRuntimeOverride_Springboot_Test` — same under Spring Boot's 
embedded servlet container, exercising the Spring `BeanStore` adapter 
end-to-end.
+
+### Phase 5 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server` and `### 
juneau-rest-server-springboot`.
+2. New section in `docs/pages/topics/RestServerComposition.md` titled 
"Runtime-overridable paths" with the precedence table, plus a one-row migration 
note in the 9.5.0 migration guide if any FINISHED-72 caller's behavior shifts 
(expected: none, fully additive).
+
+## Acceptance criteria
+
+- [ ] `RestContext.Builder.paths(String...)` overrides `@Rest(paths=...)` and 
is the highest-priority rung.
+- [ ] `getPaths()` override on a `RestServlet`/`RestObject` subclass beats the 
annotation when programmatic setter is unused.
+- [ ] `@Rest(pathsKey="x")` reads from a registered Juneau `Config`; missing 
key falls through.
+- [ ] When deployed through `juneau-rest-server-springboot`, `pathsKey` 
additionally resolves from Spring's `Environment` (after the `Config` miss).
+- [ ] Empty annotation default + empty getter + empty config-key + no 
programmatic call → resource has no top-level mounts and a clear error message 
names the resource.
+- [ ] `null` getter return is treated as "inherit annotation"; explicit empty 
array (`new String[0]`) clears the mount list.
+- [ ] Mixin works identically when registered via Juneau `BeanStore` 
(microservice path) and via Spring `@Bean` (Spring Boot path); both paths 
covered by a test.
+- [ ] Coverage ≥ 95% on the new resolution code. Full `./scripts/test.py` 
green.
+
+## Open questions
+
+1. **Precedence order.** Programmatic > config-key > getter > annotation. 
**Recommend yes** — programmatic wins because it's the most explicit rung; 
getter sits between code and config because subclass authors control it 
directly; annotation is the fallback. Consistent with how `path(String)` 
resolves today.
+2. **Null vs empty semantics.** `getPaths()` returning `null` means "inherit 
annotation"; returning `new String[0]` means "explicitly clear, no mounts". 
**Recommend that exact semantic** — gives subclasses a way to delete mounts 
entirely (rare but legal).
+3. **Single vs multi getter.** Keep the existing scalar `getPath()` for 
`@Rest(path="...")` legacy single-pattern accessor; new `getPaths()` is 
array-canonical and orthogonal. **Recommend two getters, no merge** — they 
serve different annotations.
+4. **`pathsKey` value format.** Comma-delimited string parsed at resolve time 
(e.g. `"/healthz,/readyz"`). **Recommend comma-delimited** — matches 
`Config.getStringArray(...)` and Spring's standard `String[]` binding.
+5. **Spring `@Value` / `Environment` integration.** Should 
`@Rest(pathsKey="...")` resolve from Spring's `Environment` automatically when 
running under Spring Boot, in addition to Juneau's `Config`? **Recommend yes** 
— when the resource is resolved through the Spring-`BeanStore` adapter, fall 
back to `Environment.getProperty(pathsKey)` if the Juneau `Config` lookup 
misses. Documented as a Spring-Boot-path enhancement.
+6. **SVL variable resolution in `pathsKey`-loaded values.** Should the loaded 
value run through SVL like other annotation values? **Recommend yes** for 
parity with the rest of `@Rest`'s string-typed members; 
`${env.HEALTH_PATHS:/healthz,/readyz}` should work uniformly.
+
+## Risks
+
+- **Precedence-rung ambiguity.** Users who set both a getter and a config key 
may be surprised by which wins. Mitigation: document the precedence in javadoc 
on `pathsKey`, and emit a `Logger.fine(...)` line at resolution time naming the 
chosen rung.
+- **Spring `Environment` fallback masking missing `Config`.** A user who 
intends to read from `Config` but typos the key may silently fall through to 
`Environment` and pick up an unrelated value. Mitigation: log when the fallback 
fires; document loudly.
+- **Multi-mount × programmatic-override interaction with collision 
detection.** FINISHED-72's importer-wins rule operates on the *resolved* paths; 
a runtime override that collides with another mounted resource needs to fail 
loudly (not silently overwrite). Mitigation: collision check runs on the 
resolved `String[]`, not on the annotation literal.
+- **Eclipse / Maven incremental-build staleness.** `@Rest(pathsKey=...)` is 
read at runtime, but Eclipse may cache annotation values. Mitigation: AGENTS.md 
"Build Automatically" caveat; document.
+
+## Related work
+
+- `todo/FINISHED-72-rest-mixins-and-paths.md` — established `@Rest(paths=...)` 
and the multi-mount Jetty plumbing this TODO extends.
+- `todo/TODO-74-mixin-api-docs.md` (sibling) — soft dependency; api-docs 
mixin's `paths={"/api","/openapi", ...}` defaults benefit from this primitive.
+- `todo/TODO-75-mixin-static-files.md` (sibling) — soft dependency; 
static-files mixin's `paths={"/static/*","/htdocs/*"}` defaults benefit.
+- `todo/TODO-76-mixin-convention-endpoints.md` (sibling) — soft dependency; 
favicon/seo/version/well-known mixins all default-configured paths.
+- `todo/TODO-77-mixin-ops-introspection.md` (sibling) — soft dependency; 
admin/echo/route-index mixins benefit from `pathsKey`-driven prod/staging 
variance.
+- `todo/TODO-78-mixin-jsp-module.md` (sibling) — soft dependency; JSP mixin's 
`paths={"/jsp/*"}` default benefits.
+- `juneau-rest/juneau-rest-server-springboot/` — Spring `BeanStore` adapter 
module; this TODO adds the `Environment` fallback hook.
+- `juneau-microservice/` and the `BeanStore` walk in `RestContext` — the 
microservice-path equivalent the same resolver runs against by default.
+- Existing: `RestContext.Builder.path(String)` — the scalar precedent this 
TODO mirrors for the array case.
diff --git a/todo/TODO-74-mixin-api-docs.md b/todo/TODO-74-mixin-api-docs.md
new file mode 100644
index 0000000000..0814e30683
--- /dev/null
+++ b/todo/TODO-74-mixin-api-docs.md
@@ -0,0 +1,137 @@
+# TODO-74: API-docs mixin (`BasicApiDocsResource`)
+
+Source: split out of the post-FINISHED-72 mixin-pack planning on 2026-05-23.
+
+## Goal
+
+Extract the existing `/api`, `/swagger`, `/openapi`, `/openapi.json`, 
`/openapi.yaml`, and `/redoc` endpoints from `BasicGroupOperations` / 
`BasicRestServlet` into a standalone, mixin-able `BasicApiDocsResource`. The 
first highest-value mixin candidate after `BasicHealthResource` (which landed 
in FINISHED-72): API documentation today is gated behind inheriting from 
`BasicRestServlet` / `BasicRestObject`, but plain `RestServlet` apps should be 
able to opt in via `@Rest(mixins=BasicApiDocs [...]
+
+End-state developer experience:
+
+```java
+// Path A — plain RestServlet gains the api-docs surface as a mixin.
+@Rest(path="/api", mixins=BasicApiDocsResource.class)
+public class ApiResource extends RestServlet {
+    @RestGet("/items") public List<Item> items() { ... }
+    // Now also serves /api/swagger, /api/openapi.json, /api/redoc, etc.
+}
+
+// Path B — standalone API-docs deployment via the multi-mount story (TODO-73 
+ FINISHED-72).
+@Rest(paths={"/api","/swagger","/openapi","/openapi.json","/openapi.yaml","/redoc"})
+public class BasicApiDocsResource extends BasicRestServlet { ... }
+
+// Pinned versioned mounts.
+@Rest(mixins=BasicApiDocsResource.class, paths={"/api/v3.1"})
+public class V31Docs extends BasicRestServlet {
+    @Override public String getApiFormat() { return "openapi"; }
+}
+```
+
+## Why now
+
+- FINISHED-72 added `@Rest(mixins=...)` and `@Rest(paths=...)` precisely so 
single-purpose servlet bundles like this could be composed in.
+- Today every Juneau service that wants Swagger UI / Redoc / OpenAPI 3.1 must 
extend the `BasicRestServlet` chain. The FINISHED-63 OpenAPI 3.1 work and the 
existing `apiFormat` knob already produce the artifacts; only the mounting 
surface needs lifting.
+- Pairs naturally with TODO-73 (runtime-overridable paths) — apps with 
prefixed deployments (`/admin/api`, `/internal/api`) need the override hook so 
they don't have to subclass.
+
+## Scope
+
+**In scope (v1):**
+
+- New class `org.apache.juneau.rest.docs.BasicApiDocsResource` (servlet-class 
form) with default 
`@Rest(paths={"/api","/swagger","/openapi","/openapi.json","/openapi.yaml","/redoc"})`.
+- `@RestOp`-group methods extracted directly from 
`BasicGroupOperations.getChildrenSwagger(...)` / `getChildrenOpenApi(...)` plus 
the dedicated `/swagger`, `/openapi.json`, `/openapi.yaml`, `/redoc` endpoints. 
Honors per-importer `@Rest(apiFormat=...)` so `apiFormat="openapi"` shifts the 
canonical doc to `/openapi/*` while `apiFormat="both"` keeps both sets live.
+- `?Swagger` / `?OpenApi` query mirrors continue to work from the existing 
`BasicGroupOperations` interface (mixin only adds the dedicated paths, doesn't 
remove the query overload).
+- Schema generation reuses the existing `OpenApiProvider` / `SwaggerProvider` 
infrastructure — no new schema code paths.
+- `BasicRestServlet` / `BasicRestObject` / `BasicGroupOperations` updated to 
lean on the mixin internally so the user-visible endpoints don't change but 
duplication is removed (preserves back-compat).
+- Tests in `juneau-utest`: mount-as-mixin, mount-as-standalone-via-paths, 
query-mirror still works, format pinning works, version-pinned mounts work.
+
+**Explicitly out of scope (v1):**
+
+- Custom OpenAPI extension fields (`x-foo` namespacing helpers) — separate 
concern, separate TODO if requested.
+- New schema dialects beyond Swagger v2 + OpenAPI 3.0 + 3.1 (already supported 
in core).
+- Redoc / Swagger-UI theme customization beyond the existing `HtmlDocConfig` 
surface.
+- Generation of API docs for `@RestStartCall` / `@RestEndCall` / filter beans 
— only `@RestOp` methods.
+
+## Dependency-injection notes
+
+- **Mixin instance resolution.** `BasicApiDocsResource` is instantiated via 
the FINISHED-72 mixin walk: the importer's bean store is queried via 
`BeanStore.getBean(BasicApiDocsResource.class)` first, and if no bean is 
registered, the framework reflects a no-arg constructor. Both microservice 
(`BasicBeanStore`) and Spring Boot (`SpringBeanStore` → 
`ApplicationContext.getBean(...)`) paths use this lookup verbatim — no new 
plumbing required.
+- **Builder-time configuration sourcing.** The mixin reads two builder-time 
inputs — the `apiFormat` value and the active 
`OpenApiProvider`/`SwaggerProvider`:
+    - `apiFormat` is annotation-only on the importer's `@Rest` (resolved via 
`RestContext.getApiFormat()`); identical under both DI paths.
+    - `OpenApiProvider` / `SwaggerProvider` are looked up from the bean store 
(`BeanStore.getBean(OpenApiProvider.class)` etc.). Microservice users register 
them via `@Bean OpenApiProvider provider() { ... }` on the importer; Spring 
Boot users register `@Bean OpenApiProvider provider()` in a `@Configuration` 
and the `SpringBeanStore` adapter exposes them. The mixin must NOT cache the 
provider in a static — both paths rely on per-`RestContext` lookup.
+- **Spring-Boot-specific gotchas.**
+    - When a Spring Boot application has multiple `OpenApiProvider` candidates 
(rare, but possible if a user registers both a default and a custom provider), 
Spring's `@Primary`/`@Qualifier` semantics flow through 
`SpringBeanStore.getBean(OpenApiProvider.class)` correctly because the adapter 
delegates to `ApplicationContext.getBeanProvider(...).getIfAvailable()`. 
Document the precedence so users who hit the multi-provider case know to mark 
one `@Primary`.
+    - Classpath resource resolution for any embedded HTML/JS assets the Redoc 
/ Swagger-UI handlers serve (`HtmlDocConfig`-driven) must use the importer's 
classloader, not the `BasicApiDocsResource` classloader. The existing 
`ResourceSupplier` lookup handles this; the mixin must use 
`getContext().getResourceSupplier()` rather than `getClass().getClassLoader()`.
+- **Acceptance bullet** added below: "Mixin works identically when registered 
via Juneau `BeanStore` (microservice path) and via Spring `@Bean` (Spring Boot 
path); both paths covered by a test."
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. `BasicGroupOperations` interface — confirm the existing `?Swagger` / 
`?OpenApi` query-mirror endpoints. Inspect 
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicGroupOperations.java`.
+2. `BasicRestServlet.getHtdoc(...)` and the static-file integration that backs 
Swagger UI / Redoc — confirm it doesn't conflict with TODO-75's static-files 
mixin.
+3. `OpenApiProvider` / `SwaggerProvider` `Void.class` defaults — confirm the 
bean-store fallback resolution chain works the same way it does for 
`BasicRestServlet`.
+4. `RestContext.getApiFormat()` resolution — confirm the system-property + 
annotation precedence is unchanged so the mixin sees the right format.
+
+### Phase 1 — `BasicApiDocsResource` extraction
+
+1. New class `org.apache.juneau.rest.docs.BasicApiDocsResource` with the six 
default paths and the `@RestOp` methods.
+2. Move the `?Swagger` / `?OpenApi` matchers into the docs package as static 
inner classes (or reference the existing ones in `BasicGroupOperations` 
directly).
+3. Update `BasicRestServlet` / `BasicRestObject` / `BasicGroupOperations` to 
lean on the mixin via `@Rest(mixins=BasicApiDocsResource.class)` so the 
user-visible endpoints remain identical, but the mounting code lives in one 
place.
+4. Tests:
+    - `BasicApiDocsResource_AsMixin_Test` — mount on a vanilla `RestServlet` 
via `@Rest(mixins=...)`, assert all six endpoints serve identical content to a 
`BasicRestServlet` subclass.
+    - `BasicApiDocsResource_Standalone_Test` — mount via 
`paths={"/api","/swagger",...}` directly, no importer, verify endpoints under 
each path.
+    - `BasicApiDocsResource_QueryMirror_Test` — `?Swagger` / `?OpenApi` 
continue to overload `GET /` on a parent group resource.
+    - `BasicApiDocsResource_FormatPinning_Test` — `apiFormat="openapi"` 
returns 404 on `/swagger` but 200 on `/openapi/*`; `apiFormat="swagger"` is the 
inverse; `apiFormat="both"` serves both.
+
+### Phase 2 — versioned mounts
+
+1. Confirm version-pinned mounts via `paths={"/openapi/v3.0","/openapi/v3.1"}` 
work without code change (FINISHED-72 multi-mount handles this).
+2. Tests:
+    - `BasicApiDocsResource_VersionPinned_Test` — `/openapi/v3.0` returns 3.0 
spec; `/openapi/v3.1` returns 3.1 spec on the same resource via paired mixin 
instances or `apiFormat` overrides.
+
+### Phase 3 — Spring Boot smoke test
+
+1. New test in `juneau-rest/juneau-rest-server-springboot` test sources.
+2. Tests:
+    - `BasicApiDocsResource_Springboot_Test` — register `BasicApiDocsResource` 
as a Spring `@Bean`, mount it via `JuneauRestInitializer`, verify identical 
content to the microservice mount; also exercise the multi-`OpenApiProvider` 
Spring case (two `@Bean OpenApiProvider`s, one `@Primary`).
+
+### Phase 4 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server` (new mixin) and a 
cross-reference under `### juneau-rest-server-springboot`.
+2. New sub-section in `docs/pages/topics/BasicRestServletSwagger.md` titled 
"Using `BasicApiDocsResource` as a mixin"; link from the 
`RestServerComposition.md` topic.
+
+## Acceptance criteria
+
+- [ ] Mounting `@Rest(mixins=BasicApiDocsResource.class)` on a vanilla 
`RestServlet` produces identical `/openapi.json` output to a `BasicRestServlet` 
subclass.
+- [ ] Standalone mount via 
`paths={"/api","/swagger","/openapi","/openapi.json","/openapi.yaml","/redoc"}` 
works without any importer mixin.
+- [ ] `apiFormat="openapi"` correctly 404s `/swagger` but serves `/openapi/*`; 
`apiFormat="both"` serves both surfaces.
+- [ ] `?Swagger` / `?OpenApi` query mirrors on parent group resources continue 
to work after the extraction.
+- [ ] Version-pinned mounts (`/openapi/v3.0`, `/openapi/v3.1`) coexist on the 
same resource.
+- [ ] No regression in `BasicRestServlet` / `BasicRestObject` user-visible 
endpoints (full backwards compatibility on the existing surface).
+- [ ] Mixin works identically when registered via Juneau `BeanStore` 
(microservice path) and via Spring `@Bean` (Spring Boot path); both paths 
covered by a test.
+- [ ] Coverage ≥ 95% on `BasicApiDocsResource`. Full `./scripts/test.py` green.
+
+## Open questions
+
+1. **Version-pinned mounts (`/openapi/v3.0`, `/openapi/v3.1`) — ship now or 
defer?** Cheap to ship with FINISHED-72's multi-mount + `apiFormat="both"`. 
**Recommend ship now** — the FINISHED-63 OpenAPI 3.1 work already produced the 
dual-format artifacts.
+2. **Redoc default theme.** Match Juneau's existing HTML stylesheet 
conventions (the same approach Swagger-UI uses today via `HtmlDocConfig`)? 
**Recommend yes** — mirror the Swagger-UI Juneau theme story for visual 
consistency.
+3. **Default mount paths for `apiFormat="openapi"`.** Should the default 
`paths` list still include `/swagger` (which would 404), or should it adapt at 
resolution time to drop the irrelevant entry? **Recommend keep static paths**, 
let `apiFormat` 404 the irrelevant ones — mount-time path resolution that 
depends on `apiFormat` would couple two unrelated annotation members.
+4. **`BasicGroupOperations.getChildrenSwagger(...)` / 
`getChildrenOpenApi(...)` — kept on the interface or moved to the mixin?** 
**Recommend keep on the interface** — the query-mirror behavior is tied to `GET 
/` and is a different routing seam than the dedicated paths.
+5. **Naming.** `BasicApiDocsResource` (recommended) vs `BasicApiResource` vs 
`BasicSwaggerResource`. **Recommend `BasicApiDocsResource`** — neutral across 
Swagger/OpenAPI, makes the "this is the docs surface" intent clear in import 
statements.
+
+## Risks
+
+- **Subtle regression in the existing `BasicRestServlet` chain.** Refactoring 
`BasicGroupOperations` to lean on the mixin must not change which `@RestOp` 
method matches a given URL. Mitigation: keep the existing tests passing without 
modification, then add the new mixin-mode tests.
+- **`apiFormat` × `paths` cross-product.** Five `apiFormat` values × N 
possible `paths` overrides explodes the matrix. Mitigation: parameterize the 
existing `BasicRestServlet_*` tests to also run via the mixin path; rely on 
`apiFormat`'s existing 404 behavior to keep the matrix tractable.
+- **Static-asset duplication with TODO-75.** Swagger-UI and Redoc ship JS/CSS 
assets via the static-files plumbing; if TODO-75's `BasicStaticFilesResource` 
lands first with a path collision (e.g. `/static/swagger-ui.js`), the 
importer-wins rule means a user who mixes both could shadow the docs assets. 
Mitigation: documentation note plus a 
`BasicApiDocsResource_StaticFilesCoexistence_Test`.
+- **Spring `@Primary` ambiguity for multiple `OpenApiProvider` beans.** 
Document; the `SpringBeanStore` adapter delegates to 
`getBeanProvider(...).getIfAvailable()` which honors `@Primary` correctly — but 
a user with two unmarked beans gets `BeanDefinitionOverrideException` from 
Spring, not a Juneau-friendly error.
+
+## Related work
+
+- `todo/FINISHED-72-rest-mixins-and-paths.md` — the `@Rest(mixins=...)` + 
`@Rest(paths=...)` primitives this mixin builds on.
+- `todo/FINISHED-63-openapi-3.1-emission.md` — the OpenAPI 3.1 emission this 
mixin exposes via `/openapi/*`.
+- `todo/TODO-73-rest-paths-runtime-override.md` (sibling, soft dependency) — 
runtime override of `paths` lets users move `/api` to `/admin/api` without 
subclassing.
+- `todo/TODO-75-mixin-static-files.md` (sibling) — coexistence testing for the 
static-asset overlap with Swagger-UI / Redoc.
+- `todo/TODO-77-mixin-ops-introspection.md` (sibling) — the route-index mixin 
overlaps in spirit with `/api`'s navigation surface; document that they're 
complementary, not competing.
+- `juneau-rest/juneau-rest-server-springboot/` — Spring `BeanStore` adapter; 
smoke test target for Phase 3.
+- `juneau-microservice/` and the `BeanStore` walk in `RestContext` — 
microservice-path equivalent that the same mixin runs against by default.
+- Existing: `BasicGroupOperations` 
(`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicGroupOperations.java`)
 — source of truth for the existing endpoints being extracted.
diff --git a/todo/TODO.md b/todo/TODO.md
index 92fc2e5069..806d1342b5 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -6,8 +6,6 @@
 
 - [TODO-37] - Agent instruction consolidation.
 
-- [TODO-66] Rate-limit guard + request-id propagation filter. See 
`todo/TODO-66-rate-limit-and-request-id.md`.
-
 - [TODO-67] Observability hooks — Micrometer + OpenTelemetry seams via 
`MethodExecStats`. See `todo/TODO-67-observability-micrometer-otel.md`.
 
 - [TODO-68] Bean Validation (Jakarta Validation 3.x) integration on request 
beans. See `todo/TODO-68-bean-validation-integration.md`.

Reply via email to