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 6fec81a4cc feat(rest): RateLimitGuard.Storage.snapshot() SPI + 
BasicAdminResource enrichment (TODO-89)
6fec81a4cc is described below

commit 6fec81a4ccfba5d6169dfc662656999de099a13c
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 13:53:06 2026 -0400

    feat(rest): RateLimitGuard.Storage.snapshot() SPI + BasicAdminResource 
enrichment (TODO-89)
---
 .../apache/juneau/rest/guard/RateLimitGuard.java   | 145 ++++++++++++
 .../apache/juneau/rest/ops/BasicAdminResource.java |  24 +-
 .../rest/guard/RateLimitGuard_Snapshot_Test.java   | 174 ++++++++++++++
 .../BasicAdminResource_RateLimitSnapshot_Test.java | 257 +++++++++++++++++++++
 4 files changed, 594 insertions(+), 6 deletions(-)

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
index 7e4ca1b888..896011fdc9 100644
--- 
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
@@ -378,6 +378,95 @@ public class RateLimitGuard extends RestGuard {
         */
        public record RateLimitInfo(String key, int limit, int remaining, long 
secondsUntilReset, boolean allowed) {}
 
+       /**
+        * Point-in-time view of a single token bucket as exposed by {@link 
Storage#snapshot()}.
+        *
+        * <p>
+        * Used by operator-facing tooling (e.g. {@code BasicAdminResource}'s 
{@code /admin/ratelimit} endpoint) to
+        * surface live per-key bucket state alongside the static 
configuration.  Token-bucket vocabulary
+        * intentionally — there is no discrete refill window because the 
bucket refills continuously at
+        * {@code permitsPerSecond}, so this record reports the current fill 
level ({@link #tokens()}), the integer
+        * tokens currently available ({@link #remaining()}), a convenience 
throttled flag, and the wall-clock instant
+        * of the bucket's last activity.
+        *
+        * @param key The per-request key (e.g. remote address, principal name).
+        * @param tokens The current fractional token count in the bucket.  
Continuously refilled at the
+        *      configured {@code permitsPerSecond} rate up to the configured 
capacity.
+        * @param remaining The integer number of tokens currently available — 
{@code floor(tokens)}.
+        *      Mirrors the {@code X-RateLimit-Remaining} advisory header.
+        * @param throttled <jk>true</jk> when the bucket is empty enough that 
the next request would be rejected
+        *      ({@code tokens < 1.0}).  Convenience flag for at-a-glance 
operator dashboards.
+        * @param lastRequest The wall-clock {@link Instant} at which the 
bucket was last touched.
+        *
+        * @since 9.5.0
+        */
+       public record BucketState(String key, double tokens, int remaining, 
boolean throttled, Instant lastRequest) {}
+
+       /**
+        * Returns the bucket capacity (the maximum number of tokens a bucket 
can hold).
+        *
+        * @return The bucket capacity.
+        * @since 9.5.0
+        */
+       public int getCapacity() {
+               return capacity;
+       }
+
+       /**
+        * Returns the steady-state refill rate in permits per second.
+        *
+        * @return The refill rate.
+        * @since 9.5.0
+        */
+       public double getPermitsPerSecond() {
+               return permitsPerSecond;
+       }
+
+       /**
+        * Returns whether {@code X-Forwarded-For}-aware key resolution is 
enabled.
+        *
+        * @return <jk>true</jk> if {@code X-Forwarded-For} resolution is 
enabled.
+        * @since 9.5.0
+        */
+       public boolean isXForwardedForAware() {
+               return xForwardedForAware;
+       }
+
+       /**
+        * Returns the set of request paths that bypass throttling.
+        *
+        * @return The exempt paths.  Never <jk>null</jk>.
+        * @since 9.5.0
+        */
+       public Set<String> getExemptPaths() {
+               return exemptPaths;
+       }
+
+       /**
+        * Returns the bucket-state storage backend in use by this guard.
+        *
+        * @return The storage backend.  Never <jk>null</jk>.
+        * @since 9.5.0
+        */
+       public Storage getStorage() {
+               return storage;
+       }
+
+       /**
+        * Convenience accessor for {@link Storage#snapshot()} on the 
underlying storage backend.
+        *
+        * <p>
+        * Returns an empty map when the configured {@link Storage} does not 
override
+        * {@link Storage#snapshot()} (the default behavior for storages — e.g. 
Redis-backed — that can't cheaply
+        * enumerate every bucket).
+        *
+        * @return A point-in-time map of per-key bucket state.  Never 
<jk>null</jk>.
+        * @since 9.5.0
+        */
+       public Map<String,BucketState> snapshot() {
+               return storage.snapshot();
+       }
+
        /**
         * SPI for storing per-key token-bucket state.
         *
@@ -411,6 +500,34 @@ public class RateLimitGuard extends RestGuard {
                 */
                void evict(Duration ttl);
 
+               /**
+                * Returns a point-in-time view of every per-key bucket 
currently held by this storage backend.
+                *
+                * <p>
+                * Optional operation for operational visibility.  The default 
implementation returns an empty map so
+                * that external storage backends (Redis, DynamoDB, etc.) that 
can't cheaply enumerate every bucket
+                * stay backwards-compatible without code changes.  In-memory 
implementations <b>SHOULD</b> override
+                * this to expose live bucket state — see the bundled {@link 
#inMemory()} implementation, which walks
+                * its internal map and returns one {@link BucketState} per 
entry.
+                *
+                * <p>
+                * The returned map is a snapshot, not a live view — concurrent 
modifications to the underlying
+                * storage after this method returns are not reflected.  
Per-bucket reads are individually consistent
+                * (the in-memory implementation reads each bucket under its 
own monitor), but the snapshot as a whole
+                * is not a global point-in-time consistent view.
+                *
+                * <p>
+                * <b>Cardinality warning:</b> on storages that hold many 
buckets (the bundled in-memory storage caps
+                * at 100&nbsp;000 keys by default) the returned map can be 
large.  Operator-facing callers should
+                * either filter client-side or use a paginated alternative if 
one is offered by the backend.
+                *
+                * @return A point-in-time map of per-key bucket state, keyed 
by per-request key.  Never <jk>null</jk>.
+                * @since 9.5.0
+                */
+               default Map<String,BucketState> snapshot() {
+                       return Map.of();
+               }
+
                /**
                 * Creates a new in-memory storage backend with the default 
size cap (100 000 keys).
                 *
@@ -473,6 +590,23 @@ public class RateLimitGuard extends RestGuard {
                        buckets.entrySet().removeIf(e -> 
e.getValue().lastTouchedNanos() < threshold);
                }
 
+               @Override
+               public Map<String,BucketState> snapshot() {
+                       var out = new LinkedHashMap<String,BucketState>();
+                       for (var e : buckets.entrySet()) {
+                               var b = e.getValue();
+                               var tokens = b.tokens();
+                               out.put(e.getKey(), new BucketState(
+                                       e.getKey(),
+                                       tokens,
+                                       (int) Math.floor(tokens),
+                                       tokens < 1.0,
+                                       Instant.ofEpochMilli(b.lastWallMillis())
+                               ));
+                       }
+                       return Map.copyOf(out);
+               }
+
                int size() {
                        return buckets.size();
                }
@@ -497,10 +631,12 @@ public class RateLimitGuard extends RestGuard {
 
                private double tokens;
                private long lastNanos;
+               private long lastWallMillis;
 
                Bucket(int capacity) {
                        this.tokens = capacity;
                        this.lastNanos = System.nanoTime();
+                       this.lastWallMillis = System.currentTimeMillis();
                }
 
                synchronized Storage.AcquireResult tryAcquire(int capacity, 
double permitsPerSecond) {
@@ -508,6 +644,7 @@ public class RateLimitGuard extends RestGuard {
                        var elapsedSeconds = (now - lastNanos) / 
1_000_000_000.0;
                        tokens = Math.min(capacity, tokens + elapsedSeconds * 
permitsPerSecond);
                        lastNanos = now;
+                       lastWallMillis = System.currentTimeMillis();
                        if (tokens >= 1.0) {
                                tokens -= 1.0;
                                return new Storage.AcquireResult(true, (int) 
Math.floor(tokens), secondsUntilFull(capacity, permitsPerSecond));
@@ -519,6 +656,14 @@ public class RateLimitGuard extends RestGuard {
                        return lastNanos;
                }
 
+               synchronized double tokens() {
+                       return tokens;
+               }
+
+               synchronized long lastWallMillis() {
+                       return lastWallMillis;
+               }
+
                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-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
index 5bee582918..5a32af0c0f 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
@@ -28,6 +28,7 @@ import org.apache.juneau.json.*;
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.annotation.*;
 import org.apache.juneau.rest.guard.*;
+import org.apache.juneau.rest.guard.RateLimitGuard.BucketState;
 
 /**
  * Mixin that serves operational-introspection endpoints under {@code 
/admin/*}: thread dump,
@@ -116,10 +117,14 @@ import org.apache.juneau.rest.guard.*;
  *             {@link Builder#cacheFlush(String,Runnable)}; users that want 
async semantics own the
  *             threading model.
  *     <li><b>{@code GET /admin/ratelimit}</b> &mdash; emits a JSON map keyed 
by bean name listing the
- *             registered {@link RateLimitGuard} configuration. Returns {@code 
404 Not Found} when no
- *             {@code RateLimitGuard} bean is registered on the importer's 
bean store. Bucket-level
- *             inspection (per-key counters) is reserved for a follow-on once 
{@code RateLimitGuard.Storage}
- *             exposes a snapshot SPI; v1 emits configuration only.
+ *             registered {@link RateLimitGuard} configuration and live 
per-bucket state. Each entry has
+ *             two sub-fields: {@code config} (the guard's static 
configuration &mdash; {@code class},
+ *             {@code limit}, {@code permitsPerSecond}, {@code 
xForwardedForAware}, {@code exemptPaths})
+ *             and {@code snapshot} (a sorted array of {@link BucketState} 
entries describing every
+ *             per-key bucket the storage backend currently tracks). Returns 
{@code 404 Not Found} when
+ *             no {@code RateLimitGuard} bean is registered on the importer's 
bean store. Storage
+ *             backends that don't override {@link 
RateLimitGuard.Storage#snapshot()} (e.g. Redis-backed
+ *             impls that can't cheaply enumerate buckets) emit an empty 
{@code snapshot} array.
  * </ul>
  *
  * <p>
@@ -315,9 +320,12 @@ public class BasicAdminResource {
                        throw new NotFound("No RateLimitGuard bean is 
registered.");
                var entries = new LinkedHashMap<String,Object>();
                for (var e : guards.entrySet()) {
+                       var g = e.getValue();
                        var bucket = new LinkedHashMap<String,Object>();
-                       bucket.put("config", 
describeRateLimitGuard(e.getValue()));
-                       bucket.put("buckets", List.of());
+                       bucket.put("config", describeRateLimitGuard(g));
+                       bucket.put("snapshot", g.snapshot().values().stream()
+                               .sorted(Comparator.comparing(BucketState::key))
+                               .toList());
                        entries.put(e.getKey(), bucket);
                }
                var out = new LinkedHashMap<String,Object>();
@@ -365,6 +373,10 @@ public class BasicAdminResource {
        private static Map<String,Object> describeRateLimitGuard(RateLimitGuard 
g) {
                var m = new LinkedHashMap<String,Object>();
                m.put("class", g.getClass().getName());
+               m.put("limit", g.getCapacity());
+               m.put("permitsPerSecond", g.getPermitsPerSecond());
+               m.put("xForwardedForAware", g.isXForwardedForAware());
+               m.put("exemptPaths", 
g.getExemptPaths().stream().sorted().toList());
                return m;
        }
 
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Snapshot_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Snapshot_Test.java
new file mode 100644
index 0000000000..8874afd58b
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Snapshot_Test.java
@@ -0,0 +1,174 @@
+/*
+ * 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 java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.guard.RateLimitGuard.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link RateLimitGuard.Storage#snapshot()} default-vs-override 
behavior and the
+ * shape of the {@link BucketState} records the bundled in-memory storage 
emits.
+ *
+ * @since 9.5.0
+ */
+class RateLimitGuard_Snapshot_Test extends TestBase {
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // In-memory storage surfaces live bucket state after activity.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void 
a01_inMemorySnapshotReportsTokensAndRemainingAfterPartialDrain() {
+               var s = RateLimitGuard.Storage.inMemory();
+               s.tryAcquire("k", 5, 1.0);
+               s.tryAcquire("k", 5, 1.0);
+               s.tryAcquire("k", 5, 1.0);
+               var snapshot = s.snapshot();
+               assertEquals(1, snapshot.size());
+               var b = snapshot.get("k");
+               assertNotNull(b);
+               assertEquals("k", b.key());
+               // Started full (5), three tokens consumed → at-most ~2 left, 
refill barely measurable in the test
+               // window so the actual fraction sits in (2.0, 2.0 + ε].
+               assertTrue(b.tokens() >= 2.0 && b.tokens() < 3.0,
+                       "expected tokens in [2.0, 3.0), got " + b.tokens());
+               assertEquals((int) Math.floor(b.tokens()), b.remaining());
+               assertFalse(b.throttled(), "bucket should not be throttled with 
2+ tokens left");
+               assertNotNull(b.lastRequest());
+               // Wall-clock instant should be within a few seconds of now.
+               var skewMillis = Math.abs(System.currentTimeMillis() - 
b.lastRequest().toEpochMilli());
+               assertTrue(skewMillis < 5_000L, "lastRequest drift too large: " 
+ skewMillis + "ms");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Exhausted bucket flips the throttled flag.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a02_exhaustedBucketReportsThrottledTrueAndZeroRemaining() {
+               var s = RateLimitGuard.Storage.inMemory();
+               // burst of 1, drain it, then poke once more to fail (consumes 
the refill but stays empty).
+               s.tryAcquire("k", 1, 0.01);
+               s.tryAcquire("k", 1, 0.01);
+               var b = s.snapshot().get("k");
+               assertNotNull(b);
+               assertTrue(b.tokens() < 1.0, "expected tokens < 1.0, got " + 
b.tokens());
+               assertEquals(0, b.remaining());
+               assertTrue(b.throttled(), "expected throttled=true on empty 
bucket");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Multi-key snapshot reports one entry per unique key.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a03_multiKeySnapshotHasOneEntryPerKey() {
+               var s = RateLimitGuard.Storage.inMemory();
+               s.tryAcquire("alpha", 3, 1.0);
+               s.tryAcquire("beta", 3, 1.0);
+               s.tryAcquire("gamma", 3, 1.0);
+               var snapshot = s.snapshot();
+               assertEquals(3, snapshot.size());
+               assertEquals(Set.of("alpha", "beta", "gamma"), 
snapshot.keySet());
+               for (var e : snapshot.entrySet())
+                       assertEquals(e.getKey(), e.getValue().key());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Empty storage returns an empty (but non-null) snapshot.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a04_emptyStorageSnapshotIsEmptyMap() {
+               var s = RateLimitGuard.Storage.inMemory();
+               var snapshot = s.snapshot();
+               assertNotNull(snapshot);
+               assertTrue(snapshot.isEmpty());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Snapshot returns an immutable map (defensive copy).
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void a05_snapshotIsImmutable() {
+               var s = RateLimitGuard.Storage.inMemory();
+               s.tryAcquire("k", 1, 1.0);
+               var snapshot = s.snapshot();
+               assertThrows(UnsupportedOperationException.class,
+                       () -> snapshot.put("nope", new BucketState("nope", 0.0, 
0, true, Instant.EPOCH)));
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Backwards-compat: a custom Storage impl that does NOT override 
snapshot()
+       // inherits the default which returns Map.of() without throwing.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       /** Custom storage that only implements the mandatory SPI methods. */
+       static final class C01_NoSnapshotStorage implements 
RateLimitGuard.Storage {
+               @Override public Storage.AcquireResult tryAcquire(String key, 
int capacity, double permitsPerSecond) {
+                       return new Storage.AcquireResult(true, capacity - 1, 
0L);
+               }
+               @Override public void evict(Duration ttl) {}
+       }
+
+       @Test void c01_customStorageWithoutSnapshotOverrideReturnsEmptyMap() {
+               RateLimitGuard.Storage s = new C01_NoSnapshotStorage();
+               var snapshot = s.snapshot();
+               assertNotNull(snapshot);
+               assertTrue(snapshot.isEmpty());
+               // Calling it twice should be safe.
+               assertEquals(Map.of(), s.snapshot());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // RateLimitGuard.snapshot() convenience delegates to the configured 
storage.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_guardSnapshotDelegatesToStorage() {
+               var g = RateLimitGuard.create()
+                       .permitsPerSecond(1)
+                       .burst(3)
+                       .build();
+               assertSame(g.getStorage(), g.getStorage());  // sanity — same 
instance
+               assertTrue(g.snapshot().isEmpty());
+               g.getStorage().tryAcquire("k1", 3, 1.0);
+               g.getStorage().tryAcquire("k2", 3, 1.0);
+               var snapshot = g.snapshot();
+               assertEquals(2, snapshot.size());
+               assertEquals(Set.of("k1", "k2"), snapshot.keySet());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // New public config accessors on RateLimitGuard reflect builder inputs.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void d02_guardConfigAccessorsReflectBuilderState() {
+               var g = RateLimitGuard.create()
+                       .permitsPerMinute(60)
+                       .burst(42)
+                       .xForwardedForAware(true)
+                       .exemptPaths("/healthz", "/livez")
+                       .build();
+               assertEquals(42, g.getCapacity());
+               assertEquals(1.0, g.getPermitsPerSecond(), 1e-9);
+               assertTrue(g.isXForwardedForAware());
+               assertEquals(Set.of("/healthz", "/livez"), g.getExemptPaths());
+               assertNotNull(g.getStorage());
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_RateLimitSnapshot_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_RateLimitSnapshot_Test.java
new file mode 100644
index 0000000000..a3038b17cf
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_RateLimitSnapshot_Test.java
@@ -0,0 +1,257 @@
+/*
+ * 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.ops;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.guard.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that {@link BasicAdminResource#getRateLimit} on the {@code 
/admin/ratelimit} endpoint
+ * surfaces both static {@code config} and live {@code snapshot} bucket state 
when a
+ * {@link RateLimitGuard} is registered.
+ *
+ * <p>
+ * Builds on {@code BasicAdminResource_AsMixin_Test}'s {@code 
c01_rateLimitListsRegisteredGuard}
+ * pattern; the snapshot coverage is the v1 follow-up that closes the {@code 
"buckets": []}
+ * placeholder noted in the FINISHED-77 archive.
+ *
+ * @since 9.5.0
+ */
+class BasicAdminResource_RateLimitSnapshot_Test extends TestBase {
+
+       // 
-----------------------------------------------------------------------------------------
+       // Single RateLimitGuard bean — snapshot reports per-key bucket state 
after activity.
+       //
+       // Stash the guard in a static field so the instance the GuardList 
enforces on /items is the
+       // same instance BasicAdminResource resolves from the bean store. The 
@Bean RateLimitGuard
+       // factory method below is invoked once by the bean store; both the 
guard chain (via
+       // bs.getBean(...)) and the admin endpoint pick up that single cached 
instance.
+       // 
-----------------------------------------------------------------------------------------
+
+       static final RateLimitGuard A_GUARD = RateLimitGuard.create()
+               .permitsPerSecond(1)
+               .burst(5)
+               .keyBy(req -> req.getHeader("X-Key"))
+               .exemptPaths("/admin/ratelimit", "/admin/threads", 
"/admin/heap", "/admin/cache/flush")
+               .build();
+
+       @Rest(mixins=BasicAdminResource.class)
+       public static class A extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(A_GUARD).build();
+               }
+
+               @Bean public RateLimitGuard rateLimit() { return A_GUARD; }
+
+               @RestGet(path="/items") public String items() { return "ok"; }
+       }
+
+       private static final MockRestClient ca = 
MockRestClient.buildLax(A.class);
+
+       @Test void a01_snapshotPopulatesAfterRequests() throws Exception {
+               ca.get("/items").header("X-Key", 
"client-a").run().assertStatus(200);
+               ca.get("/items").header("X-Key", 
"client-a").run().assertStatus(200);
+               ca.get("/items").header("X-Key", 
"client-a").run().assertStatus(200);
+
+               var body = 
ca.get("/admin/ratelimit").run().assertStatus(200).getContent().asString();
+               var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+               var guards = (Map<?,?>) parsed.get("guards");
+               Assertions.assertEquals(1, guards.size(), "expected exactly one 
guard entry");
+               var entry = (Map<?,?>) guards.values().iterator().next();
+
+               var config = (Map<?,?>) entry.get("config");
+               Assertions.assertNotNull(config);
+               Assertions.assertEquals(RateLimitGuard.class.getName(), 
config.get("class"));
+               Assertions.assertEquals(5, ((Number) 
config.get("limit")).intValue());
+               Assertions.assertEquals(1.0, ((Number) 
config.get("permitsPerSecond")).doubleValue(), 1e-9);
+               Assertions.assertEquals(Boolean.FALSE, 
config.get("xForwardedForAware"));
+               Assertions.assertNotNull(config.get("exemptPaths"));
+
+               var snapshot = (List<?>) entry.get("snapshot");
+               Assertions.assertNotNull(snapshot);
+               Assertions.assertFalse(snapshot.isEmpty(), "snapshot should 
contain at least one bucket");
+               // Find the client-a entry — there may also be a null-key entry 
from the admin call's
+               // missing X-Key header if it hit the guard before our exempt 
list took effect.
+               Map<?,?> clientBucket = null;
+               for (var b : snapshot) {
+                       var m = (Map<?,?>) b;
+                       if ("client-a".equals(m.get("key"))) {
+                               clientBucket = m;
+                               break;
+                       }
+               }
+               Assertions.assertNotNull(clientBucket, "expected a bucket for 
key 'client-a' in snapshot: " + snapshot);
+               var remaining = ((Number) 
clientBucket.get("remaining")).intValue();
+               Assertions.assertTrue(remaining >= 1 && remaining <= 2,
+                       "expected remaining in [1, 2] after 3 acquisitions on 
capacity-5 bucket; got " + remaining);
+               Assertions.assertEquals(Boolean.FALSE, 
clientBucket.get("throttled"),
+                       "bucket should not be throttled after only 3 of 5 
tokens consumed");
+               Assertions.assertNotNull(clientBucket.get("lastRequest"));
+       }
+
+       // 
-----------------------------------------------------------------------------------------
+       // Exhaust the bucket to flip throttled=true / remaining=0 in the 
snapshot.
+       // 
-----------------------------------------------------------------------------------------
+
+       static final RateLimitGuard B_GUARD = RateLimitGuard.create()
+               .permitsPerMinute(1)
+               .burst(1)
+               .keyBy(req -> "static")
+               .exemptPaths("/admin/ratelimit", "/admin/threads", 
"/admin/heap", "/admin/cache/flush")
+               .build();
+
+       @Rest(mixins=BasicAdminResource.class)
+       public static class B extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(B_GUARD).build();
+               }
+
+               @Bean public RateLimitGuard rateLimit() { return B_GUARD; }
+
+               @RestGet(path="/items") public String items() { return "ok"; }
+       }
+
+       private static final MockRestClient cb = 
MockRestClient.buildLax(B.class);
+
+       @Test void b01_exhaustedBucketHasThrottledTrueInSnapshot() throws 
Exception {
+               cb.get("/items").run().assertStatus(200);
+               cb.get("/items").run().assertStatus(429);
+
+               var body = 
cb.get("/admin/ratelimit").run().assertStatus(200).getContent().asString();
+               var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+               var entry = (Map<?,?>) ((Map<?,?>) 
parsed.get("guards")).values().iterator().next();
+               var snapshot = (List<?>) entry.get("snapshot");
+               Assertions.assertFalse(snapshot.isEmpty());
+               var bucket = (Map<?,?>) snapshot.get(0);
+               Assertions.assertEquals("static", bucket.get("key"));
+               Assertions.assertEquals(0, ((Number) 
bucket.get("remaining")).intValue());
+               Assertions.assertEquals(Boolean.TRUE, bucket.get("throttled"));
+       }
+
+       // 
-----------------------------------------------------------------------------------------
+       // Multiple keys → snapshot lists every key sorted ascending.
+       // 
-----------------------------------------------------------------------------------------
+
+       static final RateLimitGuard C_GUARD = RateLimitGuard.create()
+               .permitsPerSecond(1)
+               .burst(5)
+               .xForwardedForAware(true)
+               .exemptPaths("/admin/ratelimit", "/admin/threads", 
"/admin/heap", "/admin/cache/flush")
+               .build();
+
+       @Rest(mixins=BasicAdminResource.class)
+       public static class C extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).append(C_GUARD).build();
+               }
+
+               @Bean public RateLimitGuard rateLimit() { return C_GUARD; }
+
+               @RestGet(path="/items") public String items() { return "ok"; }
+       }
+
+       private static final MockRestClient cc = 
MockRestClient.buildLax(C.class);
+
+       @Test void c01_multiKeySnapshotIsSortedAscending() throws Exception {
+               cc.get("/items").header("X-Forwarded-For", 
"10.0.0.30").run().assertStatus(200);
+               cc.get("/items").header("X-Forwarded-For", 
"10.0.0.10").run().assertStatus(200);
+               cc.get("/items").header("X-Forwarded-For", 
"10.0.0.20").run().assertStatus(200);
+
+               var body = 
cc.get("/admin/ratelimit").run().assertStatus(200).getContent().asString();
+               var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+               var entry = (Map<?,?>) ((Map<?,?>) 
parsed.get("guards")).values().iterator().next();
+               var snapshot = (List<?>) entry.get("snapshot");
+               Assertions.assertTrue(snapshot.size() >= 3, "expected at least 
3 entries; got " + snapshot.size());
+               // Verify the three tenant keys are present and that the 
overall list is sorted ascending.
+               var keys = new ArrayList<String>();
+               for (var b : snapshot)
+                       keys.add((String) ((Map<?,?>) b).get("key"));
+               Assertions.assertTrue(keys.contains("10.0.0.10"));
+               Assertions.assertTrue(keys.contains("10.0.0.20"));
+               Assertions.assertTrue(keys.contains("10.0.0.30"));
+               var sorted = new ArrayList<>(keys);
+               Collections.sort(sorted);
+               Assertions.assertEquals(sorted, keys, "snapshot entries should 
be sorted ascending by key");
+       }
+
+       @Test void c02_configReflectsXForwardedForAndExemptPaths() throws 
Exception {
+               var body = 
cc.get("/admin/ratelimit").run().assertStatus(200).getContent().asString();
+               var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+               var entry = (Map<?,?>) ((Map<?,?>) 
parsed.get("guards")).values().iterator().next();
+               var config = (Map<?,?>) entry.get("config");
+               Assertions.assertEquals(Boolean.TRUE, 
config.get("xForwardedForAware"));
+               var exempt = (List<?>) config.get("exemptPaths");
+               Assertions.assertTrue(exempt.contains("/admin/ratelimit"));
+       }
+
+       // 
-----------------------------------------------------------------------------------------
+       // Custom Storage that doesn't override snapshot() → response has 
snapshot == [], no error.
+       // 
-----------------------------------------------------------------------------------------
+
+       /** No-op storage that always admits and never overrides snapshot(). */
+       static final class E01_NoSnapshotStorage implements 
RateLimitGuard.Storage {
+               @Override public RateLimitGuard.Storage.AcquireResult 
tryAcquire(String key, int capacity, double permitsPerSecond) {
+                       return new RateLimitGuard.Storage.AcquireResult(true, 
capacity, 0L);
+               }
+               @Override public void evict(Duration ttl) {}
+       }
+
+       static final RateLimitGuard E_GUARD = RateLimitGuard.create()
+               .permitsPerSecond(1)
+               .burst(1)
+               .storage(new E01_NoSnapshotStorage())
+               .build();
+
+       @Rest(mixins=BasicAdminResource.class)
+       public static class E extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @Bean public RestGuardList guards(BeanStore bs) {
+                       return RestGuardList.create(bs).build();
+               }
+
+               @Bean public RateLimitGuard rateLimit() { return E_GUARD; }
+       }
+
+       private static final MockRestClient ce = 
MockRestClient.buildLax(E.class);
+
+       @Test void e01_customStorageWithoutSnapshotOverrideEmitsEmptyArray() 
throws Exception {
+               var body = 
ce.get("/admin/ratelimit").run().assertStatus(200).getContent().asString();
+               var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+               var entry = (Map<?,?>) ((Map<?,?>) 
parsed.get("guards")).values().iterator().next();
+               Assertions.assertNotNull(entry.get("config"));
+               var snapshot = (List<?>) entry.get("snapshot");
+               Assertions.assertNotNull(snapshot);
+               Assertions.assertTrue(snapshot.isEmpty(), "snapshot should be 
empty for a storage that doesn't override snapshot()");
+       }
+}

Reply via email to