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 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> — 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 — {@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()");
+ }
+}