From 499925e10c0f8d8503379279961ce2694f0a28a3 Mon Sep 17 00:00:00 2001 From: Daniel DeGroff Date: Thu, 21 May 2026 13:08:53 -0600 Subject: [PATCH 1/3] sign test From 18502cde279758958f0f0b7e4837828ca32ac284 Mon Sep 17 00:00:00 2001 From: Daniel DeGroff Date: Thu, 21 May 2026 15:02:23 -0600 Subject: [PATCH 2/3] Fix JWKS on-miss refresh debounce to use minRefreshInterval The on-miss path in JWKS.resolve() was gated by nextDueAt, which is set to chosenInterval after a successful refresh (defaults to refreshInterval or Cache-Control max-age, typically 5-60 minutes). This blocked refresh-on-miss from picking up rotated keys until the next scheduled refresh window opened, defeating the rotation use case the feature was designed for. Split the watermarks: nextDueAt continues to pace the scheduler against the IdP's cache directive; the on-miss path now uses a separate lastAttemptAt + minRefreshInterval debounce. Singleflight + the minRefreshInterval floor still bound amplification. Adds a public lastRefreshAttempt() observability getter and removes the now-dead maxOf(minRefreshInterval, chosen) call (chosenInterval already clamps >= minRefreshInterval on every return path). Specs updated to document the two distinct watermarks. Co-Authored-By: Claude Opus 4.7 (1M context) --- specs/discovery-and-jwks-simplification.md | 3 +- specs/jwks-source.md | 39 +++++++++++------- .../java/org/lattejava/jwt/jwks/JWKS.java | 27 ++++++------ .../java/org/lattejava/jwt/jwks/JWKSTest.java | 41 ++++++++++++++++++- 4 files changed, 82 insertions(+), 28 deletions(-) diff --git a/specs/discovery-and-jwks-simplification.md b/specs/discovery-and-jwks-simplification.md index 8d649d8..723f94d 100644 --- a/specs/discovery-and-jwks-simplification.md +++ b/specs/discovery-and-jwks-simplification.md @@ -168,6 +168,7 @@ public Set keyIds(); // unmodifiable, JWKS-endpoint or public void refresh(); public int consecutiveFailures(); public Instant lastFailedRefresh(); +public Instant lastRefreshAttempt(); public Instant lastSuccessfulRefresh(); public Instant nextDueAt(); @Override public void close(); @@ -185,7 +186,7 @@ For `JWKS.of(...)`: - `resolve` and `get` work identically to the remote case. - `refresh()` is a no-op (returns normally — the snapshot is already complete). - `consecutiveFailures()` returns 0. -- `lastFailedRefresh()`, `lastSuccessfulRefresh()`, `nextDueAt()` return null. +- `lastFailedRefresh()`, `lastRefreshAttempt()`, `lastSuccessfulRefresh()`, `nextDueAt()` return null. - `close()` is a no-op (no scheduler, no inflight worker, no thread to interrupt). - `JWKS.of()` with no keys (or `of(List.of())`) is permitted, returns a non-null instance, and is not rejected at construction. `keys()` and `keyIds()` return empty collections; `get(kid)` returns null for any input; `resolve(header)` raises `MissingVerifierException` for any header. This is the same behavior as a remote-backed `JWKS` whose snapshot happens to be empty. diff --git a/specs/jwks-source.md b/specs/jwks-source.md index 7c458aa..f730047 100644 --- a/specs/jwks-source.md +++ b/specs/jwks-source.md @@ -63,8 +63,9 @@ public final class JWKSource implements VerifierResolver, AutoCloseable { // Observability (lock-free reads off the current snapshot) public Instant lastSuccessfulRefresh(); // null if no successful refresh yet public Instant lastFailedRefresh(); // null if no failure since the last success + public Instant lastRefreshAttempt(); // null if no attempt yet; advances on success and failure public int consecutiveFailures(); // 0 on the success path - public Instant nextDueAt(); // earliest time at which a refresh is allowed to start + public Instant nextDueAt(); // scheduler's next-eligible refresh time; on-miss uses lastRefreshAttempt + minRefreshInterval public Set currentKids(); // unmodifiable snapshot of kids in the cache at call time } ``` @@ -123,7 +124,7 @@ public enum CacheControlPolicy { |---|---|---| | `scheduledRefresh` | `false` | Most callers want lazy-warm + miss-driven refresh; opt in to a background thread. | | `refreshInterval` | `60 minutes` | Matches the `max-age` most IdPs publish on JWKS responses; conservative wrt rotation. | -| `refreshOnMiss` | `true` | Unknown `kid` should trigger a fetch. The combination of singleflight + `nextDueAt` bounds amplification. | +| `refreshOnMiss` | `true` | Unknown `kid` should trigger a fetch. Singleflight + the `minRefreshInterval` on-miss debounce bound amplification. | | `refreshTimeout` | `2 seconds` | Bounds blocking on `resolve()` during a miss. Long enough for healthy networks, short enough to fail fast on a wedged IdP. | | `minRefreshInterval` | `30 seconds` | Floor for both the scheduler tick rate and the on-miss debounce. Hard cap on amplification under attack. | | `cacheControlPolicy` | `CLAMP` | Honor IdP-published `max-age` when sane, but never refresh more often than `minRefreshInterval` and never wait longer than `refreshInterval` between refreshes. | @@ -197,9 +198,10 @@ The cache state is a single immutable snapshot: record Snapshot( Map byKid, Instant fetchedAt, // time of the snapshot's last successful fetch (Instant.EPOCH if never) - Instant nextDueAt, // earliest time at which a refresh is allowed to start + Instant nextDueAt, // earliest time at which the scheduler may start a refresh int consecutiveFailures, // 0 on the success path - Instant lastFailedRefresh // null if no recorded failure since the last success + Instant lastFailedRefresh, // null if no recorded failure since the last success + Instant lastAttemptAt // time of the snapshot's last refresh attempt, success or failure (Instant.EPOCH if never) ) {} ``` @@ -207,8 +209,8 @@ It is held in `AtomicReference`. Reads (`resolve()`, `currentKids()`, `build()` performs a synchronous initial load, bounded by `refreshTimeout`: -- **On success:** snapshot installed with `consecutiveFailures=0`, `fetchedAt=now`, `nextDueAt` per §2.4, `lastFailedRefresh=null`. -- **On failure:** snapshot installed with `byKid=emptyMap`, `consecutiveFailures=1`, `fetchedAt=Instant.EPOCH`, `lastFailedRefresh=now`, `nextDueAt` per the failure path in §2.7. Failure is logged at `error`. `build()` returns normally; `lastSuccessfulRefresh()` returns `null`. +- **On success:** snapshot installed with `consecutiveFailures=0`, `fetchedAt=now`, `lastAttemptAt=now`, `nextDueAt` per §2.4, `lastFailedRefresh=null`. +- **On failure:** snapshot installed with `byKid=emptyMap`, `consecutiveFailures=1`, `fetchedAt=Instant.EPOCH`, `lastFailedRefresh=now`, `lastAttemptAt=now`, `nextDueAt` per the failure path in §2.7. Failure is logged at `error`. `build()` returns normally; `lastSuccessfulRefresh()` returns `null`. Operators wanting fail-fast on initial load check `lastSuccessfulRefresh() == null` after `build()` and act accordingly. The library does not throw from `build()` on a network failure, by design — it preserves the same "availability over freshness" stance as the runtime failure path (§2.7), so a brief IdP outage at boot does not make the application unstartable. @@ -221,13 +223,13 @@ Operators wanting fail-fast on initial load check `lastSuccessfulRefresh() == nu if !v.canVerify(header.alg()): return null return v 4. if !refreshOnMiss: return null -5. if now < snapshot.nextDueAt: return null // bounded by minRefreshInterval-derived window +5. if now < snapshot.lastAttemptAt + minRefreshInterval: return null // on-miss debounce 6. fresh = singleflight.refresh() // blocks up to refreshTimeout 7. v = fresh.byKid.get(header.kid()) 8. apply step 3's canVerify check; return v or null ``` -Step 5 is the DoS gate: even if 10,000 concurrent decoders all see the same unknown `kid`, only the first one past the `nextDueAt` window starts a fetch; the rest see `nextDueAt > now` and return `null` immediately. +Step 5 is the DoS gate: even if 10,000 concurrent decoders all see the same unknown `kid`, only the first one past the `minRefreshInterval` debounce starts a fetch; the rest return `null` immediately. The debounce is intentionally distinct from the scheduler's `nextDueAt` (§2.4) — see §2.4.1 for why. If step 6's await elapses at `refreshTimeout` before the in-flight refresh completes, the in-flight fetch continues asynchronously; the await returns the current `ref.get()` (the pre-refresh snapshot), the on-miss path returns `null`, and a later decode benefits from the eventually-installed snapshot. The timeout is not a refresh failure; see §2.7.4. @@ -249,21 +251,30 @@ Synchronous, blocking, singleflight-coalesced. If a refresh is already in flight The snapshot is updated per §2.7 (prior keys preserved, `consecutiveFailures` incremented, `nextDueAt` advanced) before the exception leaves the method, *except* for `TIMEOUT` — which does not signal a refresh failure (see §2.7.4). Operators can dispatch on `e.reason()` (e.g., escalate `NON_2XX` to a health probe, swallow `TIMEOUT` quietly) without inspecting the cause chain. -`refresh()` ignores `nextDueAt`. The gate exists to defend against amplification on the on-miss / scheduler paths, not to throttle deliberate operator action. +`refresh()` ignores both `nextDueAt` and the on-miss debounce. Those gates exist to defend against amplification on the scheduler and on-miss paths respectively, not to throttle deliberate operator action. If the source has been closed, `refresh()` is a no-op and logs at `debug`. ### 2.4 The `nextDueAt` watermark -`nextDueAt` is the unified "when is the next refresh allowed to start" signal. It is consulted by both the scheduler tick (§2.5) and the on-miss path (§2.2), so the two paths cannot fight each other. +`nextDueAt` is the scheduler's "when is the next refresh allowed to start" signal. It is consulted only by the scheduler tick (§2.5). The on-miss path uses a separate debounce (§2.4.1). After a successful refresh: -- `nextDueAt = max(now + minRefreshInterval, now + chosenInterval)` where `chosenInterval` depends on `cacheControlPolicy` (see §2.6). +- `nextDueAt = now + chosenInterval` where `chosenInterval` depends on `cacheControlPolicy` (see §2.6). `chosenInterval` is itself clamped to `[minRefreshInterval, refreshInterval]`, so `nextDueAt` is always at least `now + minRefreshInterval`. After a failed refresh: - `nextDueAt = now + backoff(consecutiveFailures)` (see §2.7). -The on-miss path checks `now < nextDueAt` to decide whether to debounce. The scheduler tick checks the same condition before dispatching a refresh. There is no second cooldown variable. +### 2.4.1 The on-miss debounce + +The on-miss path (§2.2) uses `lastAttemptAt + minRefreshInterval` as its debounce, independent of `nextDueAt`. `lastAttemptAt` is set to `now` on every refresh attempt — success or failure. + +The two watermarks serve different purposes and cannot be unified: + +- `nextDueAt` paces the scheduler against the IdP's published cache directive (often 5–60 minutes via `Cache-Control: max-age`). Unifying it with the on-miss debounce would block on-miss refresh for the full `chosenInterval` after a successful fetch, defeating the rotation use case: an IdP that rotates keys mid-interval would produce JWTs with unknown `kid`s that the source refuses to fetch keys for. +- The on-miss debounce caps amplification: 10,000 concurrent unknown-`kid` resolves are coalesced by singleflight, and subsequent waves are throttled to one fetch per `minRefreshInterval` (default 30s). + +The two watermarks are independent. A successful refresh advances both; a refresh inside the `nextDueAt` window dispatched from the on-miss path will subsequently advance the scheduler's `nextDueAt` too (via the singleflight worker installing a fresh snapshot). ### 2.5 Scheduler tick @@ -312,9 +323,9 @@ When a refresh raises (network failure, non-2xx response, parse failure, etc.): #### 2.7.3 Caller-visible behavior during failure - `resolve()` continues to return cached verifiers from the prior successful snapshot. -- Misses against unknown `kid`s return `null` immediately once `nextDueAt` is in the future. +- Misses against unknown `kid`s return `null` immediately while the on-miss debounce (`lastRefreshAttempt + minRefreshInterval`) is in the future. - `lastSuccessfulRefresh()` does not advance; an integrator monitoring this can alert on staleness. -- `lastFailedRefresh()` advances to `now`; `consecutiveFailures()` increments. +- `lastFailedRefresh()` advances to `now`; `lastRefreshAttempt()` also advances to `now`; `consecutiveFailures()` increments. There is no separate "circuit breaker open" state; the exponential-backoff `nextDueAt` *is* the circuit. After enough consecutive failures, `nextDueAt` settles at `now + refreshInterval` and the source effectively reverts to "try every full interval until something changes". diff --git a/src/main/java/org/lattejava/jwt/jwks/JWKS.java b/src/main/java/org/lattejava/jwt/jwks/JWKS.java index e7fb891..3ed59f6 100644 --- a/src/main/java/org/lattejava/jwt/jwks/JWKS.java +++ b/src/main/java/org/lattejava/jwt/jwks/JWKS.java @@ -60,7 +60,7 @@ private JWKS(Builder b) { this.source = b.source; this.staticMode = false; this.url = b.url(); - this.ref.set(new Snapshot(List.of(), Map.of(), Map.of(), Instant.EPOCH, Instant.EPOCH, 0, null)); + this.ref.set(new Snapshot(List.of(), Map.of(), Map.of(), Instant.EPOCH, Instant.EPOCH, 0, null, Instant.EPOCH)); CompletableFuture initial = singleflightRefresh(); try { initial.get(refreshTimeout.toMillis(), TimeUnit.MILLISECONDS); @@ -134,7 +134,8 @@ private JWKS(List staticKeys) { Instant.EPOCH, Instant.EPOCH, 0, - null)); + null, + Instant.EPOCH)); } // --- Public static methods --- @@ -321,10 +322,6 @@ private static JWKSFetchException classifyFetchFailure(String msg, Throwable cau return new JWKSFetchException(JWKSFetchException.Reason.PARSE, msg, cause); } - private static Duration maxOf(Duration a, Duration b) { - return a.compareTo(b) >= 0 ? a : b; - } - private static List parseJWKSResponseKeys(HttpURLConnection conn, InputStream is, FetchLimits limits) { Map map = HardenedJSON.parse(is, limits); Object keys = map.get("keys"); @@ -397,6 +394,12 @@ public Instant lastFailedRefresh() { return ref.get().lastFailedRefresh(); } + public Instant lastRefreshAttempt() { + if (staticMode) return null; + Snapshot s = ref.get(); + return s.lastAttemptAt().equals(Instant.EPOCH) ? null : s.lastAttemptAt(); + } + public Instant lastSuccessfulRefresh() { if (staticMode) return null; Snapshot s = ref.get(); @@ -461,7 +464,7 @@ public Verifier resolve(Header header) { if (!refreshOnMiss) return null; Instant now = Instant.now(clock); - if (now.isBefore(snapshot.nextDueAt())) return null; + if (now.isBefore(snapshot.lastAttemptAt().plus(minRefreshInterval))) return null; CompletableFuture fut = singleflightRefresh(); try { @@ -585,15 +588,14 @@ private Snapshot doRefreshOrThrow(Snapshot prev) { throw new JWKSFetchException(JWKSFetchException.Reason.EMPTY_RESULT, "JWKS refresh produced no usable keys after JWK conversion"); } - Duration chosen = chosenInterval(resp); - Instant nextDue = now.plus(maxOf(minRefreshInterval, chosen)); + Instant nextDue = now.plus(chosenInterval(resp)); if (logger.isInfoEnabled()) { logger.info("JWKS refresh succeeded; kids=[" + byKid.keySet() + "]"); } List allKeysSnapshot = Collections.unmodifiableList(new ArrayList<>(allKeys)); Map byKidSnapshot = Collections.unmodifiableMap(new LinkedHashMap<>(byKid)); Map jwkByKidSnapshot = Collections.unmodifiableMap(new LinkedHashMap<>(jwkByKid)); - return new Snapshot(allKeysSnapshot, byKidSnapshot, jwkByKidSnapshot, now, nextDue, 0, null); + return new Snapshot(allKeysSnapshot, byKidSnapshot, jwkByKidSnapshot, now, nextDue, 0, null, now); } /** @@ -629,7 +631,7 @@ private Snapshot failureSnapshot(Snapshot prev, Instant now, Throwable cause) { } } } - return new Snapshot(allKeys, byKid, jwkByKid, fetchedAt, nextDue, next, now); + return new Snapshot(allKeys, byKid, jwkByKid, fetchedAt, nextDue, next, now, now); } private JWKSResponse fetchFromSource() { @@ -906,6 +908,7 @@ record Snapshot( Instant fetchedAt, Instant nextDueAt, int consecutiveFailures, - Instant lastFailedRefresh) { + Instant lastFailedRefresh, + Instant lastAttemptAt) { } } diff --git a/src/test/java/org/lattejava/jwt/jwks/JWKSTest.java b/src/test/java/org/lattejava/jwt/jwks/JWKSTest.java index 6840198..0f18ffa 100644 --- a/src/test/java/org/lattejava/jwt/jwks/JWKSTest.java +++ b/src/test/java/org/lattejava/jwt/jwks/JWKSTest.java @@ -453,6 +453,8 @@ public void failure_preservesPriorKeys_andAdvancesObservability() throws Excepti assertNotNull(priorSuccess); assertNull(source.lastFailedRefresh()); assertEquals(source.consecutiveFailures(), 0); + assertEquals(source.lastRefreshAttempt(), priorSuccess, + "lastRefreshAttempt matches lastSuccessfulRefresh when the last attempt succeeded"); b.responses.get("/jwks.json").status = 500; JWKSFetchException ex = expectThrows(JWKSFetchException.class, source::refresh); @@ -462,6 +464,10 @@ public void failure_preservesPriorKeys_andAdvancesObservability() throws Excepti "lastSuccessfulRefresh must not advance on failure"); assertNotNull(source.lastFailedRefresh()); assertEquals(source.consecutiveFailures(), 1); + assertTrue(source.lastRefreshAttempt().isAfter(priorSuccess), + "lastRefreshAttempt must advance on failure even when lastSuccessfulRefresh does not"); + assertEquals(source.lastRefreshAttempt(), source.lastFailedRefresh(), + "lastRefreshAttempt matches lastFailedRefresh when the last attempt failed"); assertNotNull(source.resolve(org.lattejava.jwt.Header.builder() .alg(org.lattejava.jwt.Algorithm.RS256).kid("k1").build())); @@ -968,6 +974,7 @@ public void of_static_refresh_is_noop() { jwks.refresh(); assertEquals(jwks.consecutiveFailures(), 0); assertNull(jwks.lastFailedRefresh()); + assertNull(jwks.lastRefreshAttempt()); assertNull(jwks.lastSuccessfulRefresh()); assertNull(jwks.nextDueAt()); jwks.close(); // no-op @@ -1153,6 +1160,37 @@ public void resolve_canVerifyMismatch_returnsNull() throws Exception { source.close(); } + @Test + public void resolve_onMissRefresh_picksUpRotatedKid_insideRefreshIntervalWindow() throws Exception { + // Use case: rotation inside the scheduled refresh window. With realistic intervals + // (refreshInterval far larger than minRefreshInterval), an unknown kid must still + // trigger a fetch once minRefreshInterval has elapsed since the last attempt. + String body1 = RSA_JWKS_BODY; + String body2 = body1.replace("\"k1\"", "\"k2\""); + org.lattejava.jwt.HttpServerBuilder b = new org.lattejava.jwt.HttpServerBuilder() + .listenOn(PORT) + .handleURI("/jwks.json") + .andReturn(new ExpectedResponse() + .with(r -> r.response = body1) + .with(r -> r.status = 200) + .with(r -> r.contentType = "application/json")); + startHttpServer(b); + + JWKS source = JWKS.fromJWKS("http://localhost:" + PORT + "/jwks.json") + .minRefreshInterval(Duration.ofMillis(100)) + .refreshInterval(Duration.ofMinutes(60)) + .build(); + assertEquals(source.keyIds(), java.util.Set.of("k1")); + + b.responses.get("/jwks.json").response = body2; + Thread.sleep(150); + + org.lattejava.jwt.Verifier v = source.resolve(org.lattejava.jwt.Header.builder() + .alg(org.lattejava.jwt.Algorithm.RS256).kid("k2").build()); + assertNotNull(v, "on-miss refresh must fire after minRefreshInterval, not refreshInterval"); + source.close(); + } + @Test public void resolve_onMissRefresh_findsNewlyAddedKid() throws Exception { // Use case: rotation — a fresh kid not in the cache triggers a fetch and resolves. @@ -1186,7 +1224,8 @@ public void resolve_onMissRefresh_findsNewlyAddedKid() throws Exception { @Test public void resolve_unknownKid_inside_minRefreshInterval_returnsNullWithoutFetch() throws Exception { - // Use case: §2.2 step 5 — within nextDueAt's window, second miss does not refetch. + // Use case: within the on-miss debounce window (last attempt + minRefreshInterval), a second + // miss does not refetch. Bounds amplification when many unverifiable JWTs arrive in a burst. startHttpServer(server -> server .listenOn(PORT) .handleURI("/jwks.json") From b02a833511cc6a01ad00fd46db18b902d4291c53 Mon Sep 17 00:00:00 2001 From: Daniel DeGroff Date: Thu, 21 May 2026 15:36:43 -0600 Subject: [PATCH 3/3] Address code review: stale doc/comments and failFast teardown - chosenInterval javadoc no longer references the removed outer minRefreshInterval guard; the floor is enforced internally. - Test comments in resolve_onMissRefresh_findsNewlyAddedKid and resolve_unknownKid_refreshOnMissTrue_singleflight_oneFetch... now reference the on-miss debounce window instead of nextDueAt. - Builder.build() failFast teardown now calls jwks.close() instead of only scheduler.shutdownNow(), so closed=true is set, any in-flight future is completed, and the refresh thread is interrupted before the build() exception is thrown. Co-Authored-By: Claude Opus 4.7 --- src/main/java/org/lattejava/jwt/jwks/JWKS.java | 5 ++--- src/test/java/org/lattejava/jwt/jwks/JWKSTest.java | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/lattejava/jwt/jwks/JWKS.java b/src/main/java/org/lattejava/jwt/jwks/JWKS.java index 3ed59f6..1b33b18 100644 --- a/src/main/java/org/lattejava/jwt/jwks/JWKS.java +++ b/src/main/java/org/lattejava/jwt/jwks/JWKS.java @@ -488,8 +488,7 @@ public Verifier resolve(Header header) { /** * Returns the {@link Duration} to use for {@code nextDueAt}. Honors the server's {@code Cache-Control: max-age} when - * {@link CacheControlPolicy#CLAMP} is configured, clamped into {@code [minRefreshInterval, refreshInterval]}; the - * caller applies the {@code minRefreshInterval} floor again as a final guard. + * {@link CacheControlPolicy#CLAMP} is configured, clamped into {@code [minRefreshInterval, refreshInterval]}. */ private Duration chosenInterval(JWKSResponse resp) { if (cacheControlPolicy == CacheControlPolicy.IGNORE) return refreshInterval; @@ -823,7 +822,7 @@ public JWKS build() { JWKS jwks = new JWKS(this); if (failFast && jwks.initialFetchFailure != null) { Throwable f = jwks.initialFetchFailure; - if (jwks.scheduler != null) jwks.scheduler.shutdownNow(); + jwks.close(); if (f instanceof JWKSFetchException jfe) throw jfe; if (f instanceof OpenIDConnectException oce) throw oce; throw new JWKSFetchException(JWKSFetchException.Reason.PARSE, "Initial JWKS fetch failed", f); diff --git a/src/test/java/org/lattejava/jwt/jwks/JWKSTest.java b/src/test/java/org/lattejava/jwt/jwks/JWKSTest.java index 0f18ffa..3ec2d88 100644 --- a/src/test/java/org/lattejava/jwt/jwks/JWKSTest.java +++ b/src/test/java/org/lattejava/jwt/jwks/JWKSTest.java @@ -1212,7 +1212,7 @@ public void resolve_onMissRefresh_findsNewlyAddedKid() throws Exception { assertEquals(source.keyIds(), java.util.Set.of("k1")); b.responses.get("/jwks.json").response = body2; - Thread.sleep(150); // pass nextDueAt window + Thread.sleep(150); // pass on-miss debounce window (lastAttemptAt + minRefreshInterval) org.lattejava.jwt.Verifier v = source.resolve(org.lattejava.jwt.Header.builder() .alg(org.lattejava.jwt.Algorithm.RS256).kid("k2").build()); @@ -1273,7 +1273,7 @@ public void resolve_unknownKid_refreshOnMissFalse_returnsNullWithoutFetch() thro @Test public void resolve_unknownKid_refreshOnMissTrue_singleflight_oneFetch_for_concurrent_calls() throws Exception { - // Use case: 100 concurrent unknown-kid resolves past nextDueAt coalesce into a single fetch. + // Use case: 100 concurrent unknown-kid resolves past the on-miss debounce window coalesce into a single fetch. startHttpServer(server -> server .listenOn(PORT) .handleURI("/jwks.json") @@ -1287,7 +1287,7 @@ public void resolve_unknownKid_refreshOnMissTrue_singleflight_oneFetch_for_concu .refreshInterval(Duration.ofMillis(200)) .build(); int callsAfterBuild = httpHandlers.get(httpHandlers.size() - 1).called; - Thread.sleep(250); // pass nextDueAt window + Thread.sleep(250); // pass on-miss debounce window (lastAttemptAt + minRefreshInterval) java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor(); java.util.List> futures = new java.util.ArrayList<>();