You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/07/22 18:26:47 UTC

(juneau) 01/02: Fix RateLimitGuard test flakiness with injectable time source

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 <ja...@apache.org>
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 <cu...@cursor.com>
---
 .../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;