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

commit 50c0b9356d3274007a7c621f5ba2e1fb1d0380b3
Author: James Bognar <[email protected]>
AuthorDate: Wed Jul 22 14:26:23 2026 -0400

    Fix RateLimitGuard test flakiness with injectable time source
    
    Make the InMemoryStorage/Bucket token-bucket clock injectable (a 
LongSupplier
    defaulting to System::nanoTime) so timing-sensitive tests can freeze the ~1s
    refill window. Production behavior is unchanged (Storage.inMemory()/builder
    still use the real clock; resolveKey() untouched).
    
    Wire a frozen clock into the 5 RateLimitGuard* test classes whose 200->429
    assertions previously raced the refill window and could flake under heavy 
load.
    
    Co-authored-by: Cursor <[email protected]>
---
 .../guard/RateLimitGuard_AdvisoryHeaders_Test.java |  3 ++
 .../guard/RateLimitGuard_ExemptPaths_Test.java     |  7 ++++
 .../guard/RateLimitGuard_KeyIsolation_Test.java    |  5 +++
 .../rest/server/guard/RateLimitGuard_Test.java     |  3 ++
 .../guard/RateLimitGuard_XForwardedFor_Test.java   |  5 +++
 .../juneau/rest/server/guard/RateLimitGuard.java   | 47 +++++++++++++++++++---
 6 files changed, 65 insertions(+), 5 deletions(-)

diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_AdvisoryHeaders_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_AdvisoryHeaders_Test.java
index 8281bcf8d4..83f5cf3f65 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_AdvisoryHeaders_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_AdvisoryHeaders_Test.java
@@ -66,6 +66,9 @@ class RateLimitGuard_AdvisoryHeaders_Test extends TestBase {
                                        .burst(5)
                                        .keyBy(req -> 
"static-headers-rejection")
                                        .exemptPaths()
+                                       // Frozen clock: draining all 5 burst 
tokens then hitting 429 depends on landing inside the
+                                       // same 1-second refill window, which a 
real clock can't guarantee under CPU starvation/GC pauses.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_ExemptPaths_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_ExemptPaths_Test.java
index ddbae2eed4..e9cbc8a70a 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_ExemptPaths_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_ExemptPaths_Test.java
@@ -38,6 +38,9 @@ class RateLimitGuard_ExemptPaths_Test extends TestBase {
                                        .permitsPerSecond(1)
                                        .burst(1)
                                        .keyBy(req -> "static-exempt")
+                                       // Frozen clock: the trailing 200/429 
pair below depends on landing inside the same
+                                       // 1-second refill window, which a real 
clock can't guarantee under CPU starvation/GC pauses.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
@@ -76,6 +79,8 @@ class RateLimitGuard_ExemptPaths_Test extends TestBase {
                                        .burst(1)
                                        .keyBy(req -> "static-custom-exempt")
                                        .exemptPaths("/special")
+                                       // Frozen clock: see rationale on class 
A above.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
@@ -107,6 +112,8 @@ class RateLimitGuard_ExemptPaths_Test extends TestBase {
                                        .burst(1)
                                        .keyBy(req -> "static-no-exempt")
                                        .exemptPaths()
+                                       // Frozen clock: see rationale on class 
A above.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_KeyIsolation_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_KeyIsolation_Test.java
index ed3b87a763..d9390f0b48 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_KeyIsolation_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_KeyIsolation_Test.java
@@ -37,6 +37,9 @@ class RateLimitGuard_KeyIsolation_Test extends TestBase {
                                        .burst(1)
                                        .keyBy(req -> req.getHeader("X-Tenant"))
                                        .exemptPaths()
+                                       // Frozen clock: the per-tenant 200/429 
pairs below depend on landing inside the same
+                                       // 1-second refill window, which a real 
clock can't guarantee under CPU starvation/GC pauses.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
@@ -78,6 +81,8 @@ class RateLimitGuard_KeyIsolation_Test extends TestBase {
                                        .burst(1)
                                        .keyBy(req -> null)
                                        .exemptPaths()
+                                       // Frozen clock: see rationale on class 
A above.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_Test.java
index d79370c6b7..428ab34bad 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_Test.java
@@ -45,6 +45,9 @@ class RateLimitGuard_Test extends TestBase {
                                        .burst(3)
                                        .keyBy(req -> "static-burst-drain")
                                        .exemptPaths()
+                                       // Frozen clock: draining all 3 burst 
tokens then hitting 429 depends on landing inside the
+                                       // same 1-second refill window, which a 
real clock can't guarantee under CPU starvation/GC pauses.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_XForwardedFor_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_XForwardedFor_Test.java
index d32a4f23da..2afb2d5fdf 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_XForwardedFor_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/guard/RateLimitGuard_XForwardedFor_Test.java
@@ -43,6 +43,9 @@ class RateLimitGuard_XForwardedFor_Test extends TestBase {
                                        .burst(1)
                                        .xForwardedForAware(true)
                                        .exemptPaths()
+                                       // Frozen clock: the 200/429 sequence 
below depends on landing inside the same 1-second
+                                       // refill window, which a real clock 
can't guarantee under CPU starvation/GC pauses.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
@@ -83,6 +86,8 @@ class RateLimitGuard_XForwardedFor_Test extends TestBase {
                                        .permitsPerSecond(1)
                                        .burst(1)
                                        .exemptPaths()
+                                       // Frozen clock: see rationale on class 
A above.
+                                       .storage(new 
RateLimitGuard.InMemoryStorage(100_000, () -> 0L))
                                        .build()
                        ).build();
                }
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/guard/RateLimitGuard.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/guard/RateLimitGuard.java
index 8fe9a91456..e6e8c5e9ba 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/guard/RateLimitGuard.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/guard/RateLimitGuard.java
@@ -87,7 +87,10 @@ import org.apache.juneau.rest.server.*;
  *
  * <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.
+ * map to a calendar instant, which slightly complicates debugging when the 
bucket is in an unexpected state.  The
+ * nanosecond source is pluggable on {@link InMemoryStorage} (package-private 
constructor overload) so tests can
+ * pin the refill window with a deterministic clock instead of racing real 
wall-clock time; production code always
+ * goes through the single-arg constructor, which defaults to {@code 
System::nanoTime}.
  *
  * <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>
@@ -578,16 +581,35 @@ public class RateLimitGuard extends RestGuard {
 
                private final ConcurrentHashMap<String,Bucket> buckets = new 
ConcurrentHashMap<>();
                private final int maxKeys;
+               private final LongSupplier nanoClock;
 
                InMemoryStorage(int maxKeys) {
+                       this(maxKeys, System::nanoTime);
+               }
+
+               /**
+                * Constructor allowing the nanosecond time source to be 
overridden.
+                *
+                * <p>
+                * Test-only seam.  Production code should always use {@link 
#InMemoryStorage(int)}, which defaults to
+                * real {@link System#nanoTime()}.  Tests that need to pin the 
token-bucket refill window regardless of
+                * machine speed can supply a deterministic {@link 
LongSupplier} here instead of racing {@link Thread#sleep}
+                * against real time.
+                *
+                * @param maxKeys The maximum number of keys held before 
LRU-style eviction kicks in.  Must be {@code > 0}.
+                * @param nanoClock Supplies the current time in nanoseconds 
for refill math and idle-eviction bookkeeping.  Must not be <jk>null</jk>.
+                */
+               InMemoryStorage(int maxKeys, LongSupplier nanoClock) {
+                       assertArgNotNull("nanoClock", nanoClock);
                        if (maxKeys <= 0)
                                throw new IllegalArgumentException("Argument 
'maxKeys' must be > 0.");
                        this.maxKeys = maxKeys;
+                       this.nanoClock = nanoClock;
                }
 
                @Override
                public AcquireResult tryAcquire(String key, int capacity, 
double permitsPerSecond) {
-                       var bucket = buckets.computeIfAbsent(key, k -> new 
Bucket(capacity));
+                       var bucket = buckets.computeIfAbsent(key, k -> new 
Bucket(capacity, nanoClock));
                        var result = bucket.tryAcquire(capacity, 
permitsPerSecond);
                        if (buckets.size() > maxKeys)
                                evictOldest();
@@ -596,7 +618,7 @@ public class RateLimitGuard extends RestGuard {
 
                @Override
                public void evict(Duration ttl) {
-                       var threshold = System.nanoTime() - ttl.toNanos();
+                       var threshold = nanoClock.getAsLong() - ttl.toNanos();
                        buckets.entrySet().removeIf(e -> 
e.getValue().lastTouchedNanos() < threshold);
                }
 
@@ -642,15 +664,30 @@ public class RateLimitGuard extends RestGuard {
                private double tokens;
                private long lastNanos;
                private long lastWallMillis;
+               private final LongSupplier nanoClock;
 
                Bucket(int capacity) {
+                       this(capacity, System::nanoTime);
+               }
+
+               /**
+                * Constructor allowing the nanosecond time source to be 
overridden.
+                *
+                * <p>
+                * Test-only seam — see {@link 
InMemoryStorage#InMemoryStorage(int, LongSupplier)}.
+                *
+                * @param capacity The initial (full) token count.
+                * @param nanoClock Supplies the current time in nanoseconds 
for refill math.  Must not be <jk>null</jk>.
+                */
+               Bucket(int capacity, LongSupplier nanoClock) {
+                       this.nanoClock = nanoClock;
                        this.tokens = capacity;
-                       this.lastNanos = System.nanoTime();
+                       this.lastNanos = nanoClock.getAsLong();
                        this.lastWallMillis = System.currentTimeMillis();
                }
 
                synchronized Storage.AcquireResult tryAcquire(int capacity, 
double permitsPerSecond) {
-                       var now = System.nanoTime();
+                       var now = nanoClock.getAsLong();
                        var elapsedSeconds = (now - lastNanos) / 
1_000_000_000.0;
                        tokens = Math.min(capacity, tokens + elapsedSeconds * 
permitsPerSecond);
                        lastNanos = now;

Reply via email to