From d860d5bd2ef37523ef014745bc6979ae98588230 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 19 Oct 2026 15:54:46 -0400 Subject: [PATCH 01/12] feat: base staleness on CDN fetches only, not SSE state Signed-off-by: Todd Baert --- .../openfeature/DynatraceProvider.java | 128 +++-------------- .../openfeature/DynatraceProviderTest.java | 136 +++--------------- .../openfeature/ProviderTestSupport.java | 3 +- 3 files changed, 46 insertions(+), 221 deletions(-) diff --git a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java index 8a310d3..45aa626 100644 --- a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java +++ b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java @@ -66,14 +66,6 @@ public final class DynatraceProvider extends EventProvider { static final Duration GRACE_PERIOD = Duration.ofMinutes(1); - /** - * How long to wait after an SSE fault before treating it as a real disconnect. The underlying - * event source auto-reconnects and fires its error/closed callbacks on every transient blip, - * so escalating immediately would flap STALE/READY on routine reconnects. We debounce: if the - * stream reopens within this window it was a blip (no-op); otherwise we escalate. - */ - static final Duration SSE_DISCONNECT_DEBOUNCE = Duration.ofSeconds(5); - /** SDK key format: {token}.server_{geo}_{publicRandom}.{secret}_{sha1} */ static final Pattern KEY_PATTERN = Pattern.compile( "^(dt[0-9]{2})\\.(server)_([a-z]{2,})_([a-zA-Z0-9]{10,})\\.([a-fA-F0-9]{32,})_([a-f0-9]{10,})$"); @@ -87,7 +79,6 @@ public final class DynatraceProvider extends EventProvider { private final Duration gracePeriod; private final Duration pollIntervalConnected; private final Duration pollIntervalDisconnected; - private final Duration sseDisconnectDebounce; private final DynatraceMetricsAggregator metricsAggregator; private final EvaluationMetricsFlusher metricsFlusher; @@ -99,7 +90,7 @@ public final class DynatraceProvider extends EventProvider { private final AtomicReference state = new AtomicReference<>(ProviderState.NOT_READY); - /** True while the SSE connection is considered healthy (stays true through brief blips). */ + /** True while the SSE stream is open; only selects the poll cadence. */ private final AtomicBoolean sseConnected = new AtomicBoolean(false); private volatile SseManager sseManager; @@ -112,9 +103,6 @@ public final class DynatraceProvider extends EventProvider { private volatile ScheduledExecutorService graceScheduler; private volatile ScheduledFuture graceTimer; - // debounce timer: escalates a sustained SSE fault to a real disconnect (see SSE_DISCONNECT_DEBOUNCE) - private volatile ScheduledFuture sseDisconnectTimer; - /** * Creates a provider from the SDK key. CDN URL is derived automatically from the key. * @@ -139,27 +127,21 @@ public DynatraceProvider(String sdkKey, DynatraceProviderOptions options) { // for testing DynatraceProvider(String sdkKey, String cdnUrl, FlagdCore flagdCore, CdnFetcher fetcher) { this(sdkKey, null, cdnUrl, flagdCore, fetcher, buildDefaultHttpClient(), - POLL_INTERVAL_SSE_CONNECTED, POLL_INTERVAL_SSE_DISCONNECTED, GRACE_PERIOD, SSE_DISCONNECT_DEBOUNCE); + POLL_INTERVAL_SSE_CONNECTED, POLL_INTERVAL_SSE_DISCONNECTED, GRACE_PERIOD); } // for testing: a single fixed poll interval for both SSE states (cadence independent of SSE) DynatraceProvider(String sdkKey, String cdnUrl, FlagdCore flagdCore, CdnFetcher fetcher, Duration pollInterval, Duration gracePeriod) { - this(sdkKey, cdnUrl, flagdCore, fetcher, pollInterval, gracePeriod, SSE_DISCONNECT_DEBOUNCE); - } - - // for testing: fixed poll interval plus an explicit SSE-disconnect debounce - DynatraceProvider(String sdkKey, String cdnUrl, FlagdCore flagdCore, CdnFetcher fetcher, - Duration pollInterval, Duration gracePeriod, Duration sseDisconnectDebounce) { this(sdkKey, null, cdnUrl, flagdCore, fetcher, buildDefaultHttpClient(), - pollInterval, pollInterval, gracePeriod, sseDisconnectDebounce); + pollInterval, pollInterval, gracePeriod); } // tests only: lets tests inject every collaborator individually DynatraceProvider(String sdkKey, DynatraceProviderOptions options, FlagdCore flagdCore, HttpClient httpClient) { this(sdkKey, options, buildCdnUrl(sdkKey, options), flagdCore, new CdnFetcher(buildCdnUrl(sdkKey, options), httpClient), httpClient, - POLL_INTERVAL_SSE_CONNECTED, POLL_INTERVAL_SSE_DISCONNECTED, GRACE_PERIOD, SSE_DISCONNECT_DEBOUNCE); + POLL_INTERVAL_SSE_CONNECTED, POLL_INTERVAL_SSE_DISCONNECTED, GRACE_PERIOD); } // tests only: production wiring with an overridden SSE-connected poll interval and grace period @@ -172,28 +154,28 @@ public DynatraceProvider(String sdkKey, DynatraceProviderOptions options) { private DynatraceProvider(String sdkKey, DynatraceProviderOptions options, String cdnUrl, FlagdCore flagdCore, HttpClient httpClient, Duration pollIntervalConnected, Duration gracePeriod) { this(sdkKey, options, cdnUrl, flagdCore, new CdnFetcher(cdnUrl, httpClient), httpClient, - pollIntervalConnected, POLL_INTERVAL_SSE_DISCONNECTED, gracePeriod, SSE_DISCONNECT_DEBOUNCE); + pollIntervalConnected, POLL_INTERVAL_SSE_DISCONNECTED, gracePeriod); } // tests only: as above, plus a pre-built CdnFetcher and timing controls (fixed cadence both states) DynatraceProvider(String sdkKey, DynatraceProviderOptions options, FlagdCore flagdCore, CdnFetcher fetcher, Duration pollInterval, Duration gracePeriod) { this(sdkKey, options, buildCdnUrl(sdkKey, options), flagdCore, fetcher, buildDefaultHttpClient(), - pollInterval, pollInterval, gracePeriod, SSE_DISCONNECT_DEBOUNCE); + pollInterval, pollInterval, gracePeriod); } // for testing: distinct connected/disconnected cadences, to observe the SSE-transition consequence DynatraceProvider(String sdkKey, String cdnUrl, FlagdCore flagdCore, CdnFetcher fetcher, Duration pollIntervalConnected, Duration pollIntervalDisconnected, - Duration gracePeriod, Duration sseDisconnectDebounce) { + Duration gracePeriod) { this(sdkKey, null, cdnUrl, flagdCore, fetcher, buildDefaultHttpClient(), - pollIntervalConnected, pollIntervalDisconnected, gracePeriod, sseDisconnectDebounce); + pollIntervalConnected, pollIntervalDisconnected, gracePeriod); } private DynatraceProvider(String sdkKey, DynatraceProviderOptions options, String cdnUrl, FlagdCore flagdCore, CdnFetcher fetcher, HttpClient metricsHttpClient, Duration pollIntervalConnected, Duration pollIntervalDisconnected, - Duration gracePeriod, Duration sseDisconnectDebounce) { + Duration gracePeriod) { String trimmedSdkKey = validateSdkKey(sdkKey).group(0); DynatraceProviderOptions safeOptions = options == null ? DynatraceProviderOptions.builder().build() : options; this.cdnUrl = cdnUrl; @@ -203,7 +185,6 @@ private DynatraceProvider(String sdkKey, DynatraceProviderOptions options, Strin this.pollIntervalConnected = pollIntervalConnected; this.pollIntervalDisconnected = pollIntervalDisconnected; this.gracePeriod = gracePeriod; - this.sseDisconnectDebounce = sseDisconnectDebounce; this.metricsAggregator = new DynatraceMetricsAggregator(safeOptions.getMetricsHardCapDistinctFlags()); this.metricsFlusher = new EvaluationMetricsFlusher( metricsAggregator, @@ -445,7 +426,6 @@ public void shutdown() { pm.stop(); } cancelGraceTimer(); - cancelSseDisconnectTimer(); ScheduledExecutorService gs = graceScheduler; if (gs != null) { gs.shutdown(); @@ -460,72 +440,28 @@ public void shutdown() { log.info("Dynatrace provider shut down"); } - // -- SSE callbacks (package-private for testing) -- - + // SSE only selects the poll cadence; provider state follows CDN fetch outcomes alone synchronized void onSseConnected() { - // A (re)connect aborts any pending disconnect escalation. If one was pending, the stream - // just blipped, so pull current config in case we missed a change while it was down. - boolean hadPendingDisconnect = cancelSseDisconnectTimer(); - boolean wasConnected = sseConnected.getAndSet(true); + sseConnected.set(true); PollingManager pm = pollingManager; - if (pm != null) { - pm.setInterval(pollIntervalConnected); - } - if (!wasConnected) { - log.info("SSE connected; poll interval switched to {}", pollIntervalConnected); - } - if (pm != null && (!wasConnected || hadPendingDisconnect)) { - // Reconnect catch-up: no announced lastModified, so this re-fetch is never dropped. - pm.requestOutOfBandFetch(null); - } - } - - /** - * SSE fault callback. The event source auto-reconnects and fires this on every transient blip, - * so we don't escalate immediately: we start a debounce timer (idempotent across repeat faults) - * and only treat it as a real disconnect if the stream hasn't reopened by the time it fires. - */ - synchronized void onSseDisconnected() { - if (!sseConnected.get()) { - // already escalated to disconnected; nothing to do - return; - } - if (sseDisconnectTimer != null && !sseDisconnectTimer.isDone()) { - // debounce already running; this is a repeat fault callback + if (pm == null) { return; } - log.debug("SSE fault; starting {}ms disconnect debounce", sseDisconnectDebounce.toMillis()); - try { - sseDisconnectTimer = graceScheduler.schedule( - this::escalateSseDisconnect, - sseDisconnectDebounce.toMillis(), - TimeUnit.MILLISECONDS); - } catch (RejectedExecutionException e) { - // scheduler already shut down; ignore - } + log.info("SSE connected; poll interval switched to {}", pollIntervalConnected); + pm.setInterval(pollIntervalConnected); + // catch up on anything announced while the stream was down; never dropped + pm.requestOutOfBandFetch(null); } - /** - * Runs when an SSE fault has persisted past the debounce window: switch to the aggressive poll - * cadence and go STALE. Switching the interval re-anchors the ticker, so the first fast poll is - * one (now short) interval away rather than up to a full SSE-connected interval, which closes - * the cadence gap without an extra out-of-band fetch that would instantly flap STALE to READY. - */ - private synchronized void escalateSseDisconnect() { + synchronized void onSseDisconnected() { if (!sseConnected.getAndSet(false)) { - return; // a reconnect beat us to it + return; } - log.warn("SSE disconnected; increasing poll frequency to {}", pollIntervalDisconnected); + log.warn("SSE disconnected; poll interval switched to {}", pollIntervalDisconnected); PollingManager pm = pollingManager; if (pm != null) { pm.setInterval(pollIntervalDisconnected); } - if (state.compareAndSet(ProviderState.READY, ProviderState.STALE)) { - emitProviderStale(ProviderEventDetails.builder() - .message("SSE disconnected") - .build()); - startGraceTimer(); - } } /** The poll/fetch coordinator, or null before {@link #initialize} has run. */ @@ -533,17 +469,6 @@ PollingManager pollingManager() { return pollingManager; } - private synchronized boolean cancelSseDisconnectTimer() { - ScheduledFuture timer = sseDisconnectTimer; - if (timer == null) { - return false; - } - boolean wasPending = !timer.isDone(); - timer.cancel(false); - sseDisconnectTimer = null; - return wasPending; - } - /** * Handles a parsed SSE message per the SSE message format spec: *
    @@ -621,26 +546,15 @@ private synchronized void onFetchSuccess() { } } - /** - * Handles a poll failure. - * - *

    Per spec 4.1: a poll failure stays {@code READY} when SSE is healthy, since SSE - * continues to deliver notifications and the next poll will retry. Only - * when SSE is also down do we transition to {@code STALE}. - */ private synchronized void onFetchFailure(String reason) { - if (sseConnected.get()) { - log.warn("poll failed (SSE healthy, staying READY): {}", reason); - return; - } if (state.compareAndSet(ProviderState.READY, ProviderState.STALE)) { - log.warn("poll failed and SSE disconnected, transitioning to STALE: {}", reason); + log.warn("fetch failed, transitioning to STALE: {}", reason); emitProviderStale(ProviderEventDetails.builder() .message(reason) .build()); startGraceTimer(); } else { - log.warn("poll failed (state={}): {}", state.get(), reason); + log.warn("fetch failed (state={}): {}", state.get(), reason); } } diff --git a/src/test/java/com/dynatrace/openfeature/DynatraceProviderTest.java b/src/test/java/com/dynatrace/openfeature/DynatraceProviderTest.java index 0a04c50..da47933 100644 --- a/src/test/java/com/dynatrace/openfeature/DynatraceProviderTest.java +++ b/src/test/java/com/dynatrace/openfeature/DynatraceProviderTest.java @@ -45,9 +45,6 @@ class DynatraceProviderTest { private static final RetryStrategy NO_DELAY_RETRY = new RetryStrategy(new Random(42), ms -> {}); - /** short SSE-disconnect debounce so escalation is observable quickly in tests */ - private static final Duration TEST_SSE_DEBOUNCE = Duration.ofMillis(100); - @BeforeEach void setUp() throws IOException { server = new MockWebServer(); @@ -84,7 +81,7 @@ private DynatraceProvider fastProvider(Duration gracePeriod) { private DynatraceProvider provider(Duration pollInterval, Duration gracePeriod) { CdnFetcher fetcher = new CdnFetcher(mockCdnUrl(), httpClient, NO_DELAY_RETRY); return new DynatraceProvider(TEST_KEY, mockCdnUrl(), new FlagdCore(), fetcher, - pollInterval, gracePeriod, TEST_SSE_DEBOUNCE); + pollInterval, gracePeriod); } /** Polls {@code provider.getState()} until it equals {@code expected} or the timeout elapses. */ @@ -726,146 +723,54 @@ void disableSseOptionWorks() { } @Test - void pollFailureStaysReadyWhenSseConnected() throws Exception { - // init succeeds + void pollFailureGoesStaleEvenWhenSseConnected() throws Exception { server.enqueue(new MockResponse().setResponseCode(200).setBody(FLAG_CONFIG)); - // subsequent polls fail for (int i = 0; i < RetryStrategy.MAX_ATTEMPTS * 5; i++) { server.enqueue(new MockResponse().setResponseCode(503)); } DynatraceProvider p = fastProvider(Duration.ofSeconds(10)); p.initialize(new ImmutableContext()); - - // simulate SSE connection: provider should stay READY despite poll failures - p.onSseConnected(); - - Thread.sleep(300); // allow polls to fire and fail - - assertEquals(ProviderState.READY, p.getState()); - p.shutdown(); - } - - @Test - void sseDisconnectTransitionsToStale() throws Exception { - // Long poll interval so the only fetches are SSE-triggered, not the background poller. - // Disconnect is debounced, so STALE arrives shortly after the debounce window elapses. - server.enqueue(new MockResponse().setResponseCode(200).setBody(FLAG_CONFIG)); - // absorb the immediate fetch fired on connect and on disconnect escalation - server.enqueue(new MockResponse().setResponseCode(304)); - server.enqueue(new MockResponse().setResponseCode(304)); - - DynatraceProvider p = new DynatraceProvider( - TEST_KEY, mockCdnUrl(), new FlagdCore(), - new CdnFetcher(mockCdnUrl(), httpClient, NO_DELAY_RETRY), - Duration.ofSeconds(30), Duration.ofSeconds(30), TEST_SSE_DEBOUNCE); - p.initialize(new ImmutableContext()); p.onSseConnected(); - assertEquals(ProviderState.READY, p.getState()); - - // a sustained disconnect (debounce elapses with no reconnect) escalates to STALE - p.onSseDisconnected(); + // SSE delivers no configs, so a failing CDN means stale data regardless awaitState(p, ProviderState.STALE, 2_000); - - p.shutdown(); - } - - @Test - void sseBlipReconnectDoesNotGoStale() throws Exception { - // A fault immediately followed by a reconnect (within the debounce window) is a blip: - // the provider must stay READY and never emit STALE. - server.enqueue(new MockResponse().setResponseCode(200).setBody(FLAG_CONFIG)); - server.enqueue(new MockResponse().setResponseCode(304)); // connect-triggered fetch - server.enqueue(new MockResponse().setResponseCode(304)); // reconnect-triggered fetch - - DynatraceProvider p = new DynatraceProvider( - TEST_KEY, mockCdnUrl(), new FlagdCore(), - new CdnFetcher(mockCdnUrl(), httpClient, NO_DELAY_RETRY), - Duration.ofSeconds(30), Duration.ofSeconds(30), TEST_SSE_DEBOUNCE); - p.initialize(new ImmutableContext()); - p.onSseConnected(); - - // fault then reconnect before the debounce fires - p.onSseDisconnected(); - p.onSseConnected(); - - // wait past the debounce window; state must remain READY (no escalation happened) - Thread.sleep(TEST_SSE_DEBOUNCE.toMillis() + 200); - assertEquals(ProviderState.READY, p.getState()); - p.shutdown(); } @Test - void sseDisconnectDeduplicatesMultipleCalls() throws Exception { - // long poll interval: no background recovery can interfere with the assertion + void sseDisconnectStaysReady() throws Exception { server.enqueue(new MockResponse().setResponseCode(200).setBody(FLAG_CONFIG)); - server.enqueue(new MockResponse().setResponseCode(304)); - server.enqueue(new MockResponse().setResponseCode(304)); + server.enqueue(new MockResponse().setResponseCode(304)); // connect catch-up DynatraceProvider p = new DynatraceProvider( TEST_KEY, mockCdnUrl(), new FlagdCore(), new CdnFetcher(mockCdnUrl(), httpClient, NO_DELAY_RETRY), - Duration.ofSeconds(30), Duration.ofSeconds(30), TEST_SSE_DEBOUNCE); + Duration.ofSeconds(30), Duration.ofSeconds(30)); p.initialize(new ImmutableContext()); p.onSseConnected(); - // repeated fault callbacks (the event source fires these on every blip) must not throw and - // must collapse to a single escalation + // repeated fault callbacks, as the event source fires on every blip assertDoesNotThrow(p::onSseDisconnected); assertDoesNotThrow(p::onSseDisconnected); - assertDoesNotThrow(p::onSseDisconnected); - awaitState(p, ProviderState.STALE, 2_000); - - p.shutdown(); - } - - @Test - void sseDisconnectGoesStaleThenRecoversThroughPoll() throws Exception { - // First request serves the config; afterwards the CDN's health is toggled by cdnFailing, - // so STALE persists while it fails and the fast poll cadence recovers once it is healthy. - AtomicBoolean cdnFailing = new AtomicBoolean(false); - server.setDispatcher(new Dispatcher() { - private boolean served; - @Override - public MockResponse dispatch(RecordedRequest request) { - if (!served) { - served = true; - return new MockResponse().setResponseCode(200).setBody(FLAG_CONFIG); - } - return cdnFailing.get() - ? new MockResponse().setResponseCode(503) - : new MockResponse().setResponseCode(304); - } - }); - - DynatraceProvider p = fastProvider(Duration.ofSeconds(10)); - p.initialize(new ImmutableContext()); - p.onSseConnected(); - - // CDN starts failing, then SSE drops: the debounced disconnect escalates to STALE and the - // failing fast polls keep it there. - cdnFailing.set(true); - p.onSseDisconnected(); - awaitState(p, ProviderState.STALE, 2_000); + Thread.sleep(200); - // CDN healthy again: the fast poll cadence recovers to READY - cdnFailing.set(false); - awaitState(p, ProviderState.READY, 2_000); + // SSE only selects the cadence; state follows fetch outcomes alone + assertEquals(ProviderState.READY, p.getState()); p.shutdown(); } @Test - void sseConnectTriggersImmediateFetch() throws Exception { - // long poll interval so the only fetch after init is the one triggered by connect + void sseConnectAndReconnectEachTriggerACatchUpFetch() throws Exception { + // long poll interval so the only fetches after init are the connect catch-ups server.enqueue(new MockResponse().setResponseCode(200).setBody(FLAG_CONFIG)); server.enqueue(new MockResponse().setResponseCode(304)); + server.enqueue(new MockResponse().setResponseCode(304)); DynatraceProvider p = new DynatraceProvider( TEST_KEY, mockCdnUrl(), new FlagdCore(), new CdnFetcher(mockCdnUrl(), httpClient, NO_DELAY_RETRY), - Duration.ofSeconds(60), Duration.ofSeconds(60), TEST_SSE_DEBOUNCE); + Duration.ofSeconds(60), Duration.ofSeconds(60)); p.initialize(new ImmutableContext()); server.takeRequest(); // initial fetch @@ -873,6 +778,11 @@ TEST_KEY, mockCdnUrl(), new FlagdCore(), assertNotNull(server.takeRequest(2, java.util.concurrent.TimeUnit.SECONDS), "SSE connect should trigger an immediate re-fetch to catch missed changes"); + + p.onSseDisconnected(); + p.onSseConnected(); + assertNotNull(server.takeRequest(2, java.util.concurrent.TimeUnit.SECONDS), + "SSE reconnect should trigger a catch-up re-fetch"); p.shutdown(); } @@ -880,7 +790,7 @@ TEST_KEY, mockCdnUrl(), new FlagdCore(), void pollCadenceSlowsWhenSseConnectedAndSpeedsUpOnDisconnect() throws Exception { // Assert the consequence of the SSE transitions (not just the state change): the poll // cadence is the slow safety-net interval while connected and the fast interval once - // disconnect escalates. We count fetches over equal windows in each mode. + // disconnected. We count fetches over equal windows in each mode. AtomicInteger fetches = new AtomicInteger(); server.setDispatcher(new Dispatcher() { private boolean served; @@ -900,7 +810,7 @@ public MockResponse dispatch(RecordedRequest request) { DynatraceProvider p = new DynatraceProvider( TEST_KEY, mockCdnUrl(), new FlagdCore(), new CdnFetcher(mockCdnUrl(), httpClient, NO_DELAY_RETRY), - connected, disconnected, Duration.ofSeconds(30), TEST_SSE_DEBOUNCE); + connected, disconnected, Duration.ofSeconds(30)); p.initialize(new ImmutableContext()); // connected, slow cadence: no periodic poll should fire in a 300ms window @@ -910,9 +820,9 @@ TEST_KEY, mockCdnUrl(), new FlagdCore(), Thread.sleep(300); int whileConnected = fetches.get(); - // disconnected, after debounce, fast cadence: many polls in the same window + // disconnected, fast cadence: many polls in the same window p.onSseDisconnected(); - Thread.sleep(TEST_SSE_DEBOUNCE.toMillis() + 100); // let escalation/interval switch settle + Thread.sleep(100); // let the interval switch settle fetches.set(0); Thread.sleep(300); int whileDisconnected = fetches.get(); diff --git a/src/test/java/com/dynatrace/openfeature/ProviderTestSupport.java b/src/test/java/com/dynatrace/openfeature/ProviderTestSupport.java index 2637be0..aa3efbb 100644 --- a/src/test/java/com/dynatrace/openfeature/ProviderTestSupport.java +++ b/src/test/java/com/dynatrace/openfeature/ProviderTestSupport.java @@ -22,7 +22,8 @@ public final class ProviderTestSupport { public static final Duration GRACE_PERIOD = timing("GRACE_PERIOD_MS"); /** The cadence while SSE is disabled or disconnected; the CDN spec runs with SSE disabled. */ public static final Duration SSE_DISCONNECTED_POLL_INTERVAL = DynatraceProvider.POLL_INTERVAL_SSE_DISCONNECTED; - public static final Duration DISCONNECT_DEBOUNCE_TIME_WINDOW = DynatraceProvider.SSE_DISCONNECT_DEBOUNCE; + // harness timing only; the provider no longer debounces disconnects + public static final Duration DISCONNECT_DEBOUNCE_TIME_WINDOW = Duration.ofSeconds(5); private ProviderTestSupport() { } From 3d7e8f9e2a5820642389150ea6d85318cbbbde5b Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 19 Oct 2026 16:16:39 -0400 Subject: [PATCH 02/12] fixup: absorb the SSE staleness harness scenarios Signed-off-by: Todd Baert --- .gitmodules | 1 + fm-provider-test-harness | 2 +- .../openfeature/ProviderTestSupport.java | 2 -- .../openfeature/e2e/E2eStepDefinitions.java | 34 ++++--------------- 4 files changed, 8 insertions(+), 31 deletions(-) diff --git a/.gitmodules b/.gitmodules index 3db825f..39489fe 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "fm-provider-test-harness"] path = fm-provider-test-harness url = https://github.com/Dynatrace/fm-provider-test-harness.git + branch = feat/sse-independent-staleness diff --git a/fm-provider-test-harness b/fm-provider-test-harness index ee15379..aa54503 160000 --- a/fm-provider-test-harness +++ b/fm-provider-test-harness @@ -1 +1 @@ -Subproject commit ee153794814ac3526e3e80dc30eb4ad24c82f14b +Subproject commit aa54503ca2b6548b6bad38f6042c16133fef0587 diff --git a/src/test/java/com/dynatrace/openfeature/ProviderTestSupport.java b/src/test/java/com/dynatrace/openfeature/ProviderTestSupport.java index aa3efbb..2c74183 100644 --- a/src/test/java/com/dynatrace/openfeature/ProviderTestSupport.java +++ b/src/test/java/com/dynatrace/openfeature/ProviderTestSupport.java @@ -22,8 +22,6 @@ public final class ProviderTestSupport { public static final Duration GRACE_PERIOD = timing("GRACE_PERIOD_MS"); /** The cadence while SSE is disabled or disconnected; the CDN spec runs with SSE disabled. */ public static final Duration SSE_DISCONNECTED_POLL_INTERVAL = DynatraceProvider.POLL_INTERVAL_SSE_DISCONNECTED; - // harness timing only; the provider no longer debounces disconnects - public static final Duration DISCONNECT_DEBOUNCE_TIME_WINDOW = Duration.ofSeconds(5); private ProviderTestSupport() { } diff --git a/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java b/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java index 39ff837..907890a 100644 --- a/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java +++ b/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java @@ -257,44 +257,22 @@ public void pollingTriggersAConfigurationRefetch() { awaitCdnRequestWithin(POLL_TIMEOUT); } - @When("polling triggers a configuration refetch after the SSE_DISCONNECTED_POLL_INTERVAL") - public void pollingTriggersAConfigurationRefetchAfterTheSseDisconnectedPollInterval() { - awaitCdnRequestWithin(ProviderTestSupport.SSE_DISCONNECTED_POLL_INTERVAL); - } - - @When("polling triggers a configuration refetch after the SSE_CONNECTED_POLL_INTERVAL") - public void pollingTriggersAConfigurationRefetchAfterTheSseConnectedPollInterval() { - awaitCdnRequestWithin(ProviderTestSupport.SSE_CONNECTED_POLL_INTERVAL); - } - /** * A blip: the backend drops the stream and refuses connections for {@code outageSeconds}, then accepts them again. */ - @When("the SSE stream drops for {int} seconds and reconnects within the DISCONNECT_DEBOUNCE_TIME_WINDOW") - public void theSseStreamDropsForSecondsAndReconnectsWithinTheDebounceWindow(int outageSeconds) { - Duration debounce = ProviderTestSupport.DISCONNECT_DEBOUNCE_TIME_WINDOW; + @When("the SSE stream drops for {int} seconds and reconnects") + public void theSseStreamDropsForSecondsAndReconnects(int outageSeconds) { mockServer.dropSse(Duration.ofSeconds(outageSeconds)); - assertTrue(pollUntil(debounce, () -> mockServer.sseClientCount() >= 1), - "SSE did not reconnect within the " + debounce.toSeconds() + "s disconnect debounce window"); + assertTrue(pollUntil(SSE_RECONNECT_TIMEOUT, () -> mockServer.sseClientCount() >= 1), + "SSE did not reconnect after the " + outageSeconds + "s outage"); } /** * A sustained outage: connections stay refused until the stream is restored or reset. */ - @When("the SSE stream drops and stays down past the DISCONNECT_DEBOUNCE_TIME_WINDOW") - public void theSseStreamDropsAndStaysDownPastTheDebounceWindow() { + @When("the SSE stream drops and stays down") + public void theSseStreamDropsAndStaysDown() { mockServer.dropSse(); - // Hand the provider its debounce window (plus slack) to escalate the fault to a disconnect; - // the following Then steps assert what that escalation did. - pollUntil(ProviderTestSupport.DISCONNECT_DEBOUNCE_TIME_WINDOW.plus(SETTLE), - () -> client.getProviderState() != ProviderState.READY); - } - - @When("the SSE stream reconnects") - public void theSseStreamReconnects() { - mockServer.restoreSse(); - assertTrue(pollUntil(SSE_RECONNECT_TIMEOUT, () -> mockServer.sseClientCount() >= 1), - "SSE did not reconnect after the outage was lifted"); } @When("^the server emits an SSE message (.*)$") From 6150595a39f195df75c09e97894696954285f5e9 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 19 Oct 2026 16:30:14 -0400 Subject: [PATCH 03/12] fixup: assert a disconnect emits no PROVIDER_STALE Signed-off-by: Todd Baert --- fm-provider-test-harness | 2 +- .../dynatrace/openfeature/e2e/E2eStepDefinitions.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/fm-provider-test-harness b/fm-provider-test-harness index aa54503..7adcf5d 160000 --- a/fm-provider-test-harness +++ b/fm-provider-test-harness @@ -1 +1 @@ -Subproject commit aa54503ca2b6548b6bad38f6042c16133fef0587 +Subproject commit 7adcf5dac9979d7ad039eea5373800e30447fe94 diff --git a/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java b/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java index 907890a..0f7baa4 100644 --- a/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java +++ b/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java @@ -355,6 +355,16 @@ public void aProviderEventIsEmitted(String eventName) { consumeEvent(ProviderEvent.valueOf(eventName)); } + // whole scenario, not from the cursor: a STALE that already recovered must still fail + @Then("no {word} event is emitted") + public void noProviderEventIsEmitted(String eventName) { + ProviderEvent unwanted = ProviderEvent.valueOf(eventName); + synchronized (events) { + assertTrue(events.stream().noneMatch(recorded -> recorded.event == unwanted), + "unexpected " + eventName + " event was emitted"); + } + } + @Then("a PROVIDER_CONFIGURATION_CHANGED event is emitted with changed flag {string}") public void aConfigurationChangedEventIsEmittedWithChangedFlag(String flag) { EventDetails details = consumeEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED); From 41cd467f237fa6b280b96dde21043bd24c29f2b5 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 19 Oct 2026 16:31:42 -0400 Subject: [PATCH 04/12] fixup: bump harness Signed-off-by: Todd Baert --- fm-provider-test-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fm-provider-test-harness b/fm-provider-test-harness index 7adcf5d..9d2551c 160000 --- a/fm-provider-test-harness +++ b/fm-provider-test-harness @@ -1 +1 @@ -Subproject commit 7adcf5dac9979d7ad039eea5373800e30447fe94 +Subproject commit 9d2551c3c26d747a5c618b65b2b2ccac4a7227c4 From 625ad32c954a1a6689710fd2aae05c345961c929 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 20 Oct 2026 11:03:14 -0400 Subject: [PATCH 05/12] fixup: bump harness, bind the GRACE_PERIOD step Signed-off-by: Todd Baert --- fm-provider-test-harness | 2 +- .../java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fm-provider-test-harness b/fm-provider-test-harness index 9d2551c..d59b6af 160000 --- a/fm-provider-test-harness +++ b/fm-provider-test-harness @@ -1 +1 @@ -Subproject commit 9d2551c3c26d747a5c618b65b2b2ccac4a7227c4 +Subproject commit d59b6afda2298e24f151a311b3cbbf457ea6d92d diff --git a/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java b/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java index 0f7baa4..1c69121 100644 --- a/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java +++ b/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java @@ -284,7 +284,7 @@ public void theServerEmitsAnSseMessage(String rawPayload) { pollUntil(SETTLE, () -> mockServer.countCdnRequests() > baseline); } - @When("the grace period expires") + @When("the GRACE_PERIOD expires") public void theGracePeriodExpires() { pollUntil(ProviderTestSupport.GRACE_PERIOD.plus(SETTLE), () -> client.getProviderState() == ProviderState.ERROR); } From 84a81cead66f74dd6136d1b1d053c2f47f74f98f Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 20 Oct 2026 13:57:22 -0400 Subject: [PATCH 06/12] fixup: pin harness to v1.3.0 Signed-off-by: Todd Baert --- .gitmodules | 1 - fm-provider-test-harness | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 39489fe..3db825f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,3 @@ [submodule "fm-provider-test-harness"] path = fm-provider-test-harness url = https://github.com/Dynatrace/fm-provider-test-harness.git - branch = feat/sse-independent-staleness diff --git a/fm-provider-test-harness b/fm-provider-test-harness index d59b6af..aad147b 160000 --- a/fm-provider-test-harness +++ b/fm-provider-test-harness @@ -1 +1 @@ -Subproject commit d59b6afda2298e24f151a311b3cbbf457ea6d92d +Subproject commit aad147bcf0d24ff00dfc268464492aeac5367095 From 0be643614986b84a4c69419ff8d223f3f392ca9c Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 22 Oct 2026 14:40:49 -0400 Subject: [PATCH 07/12] fixup: bump harness to v1.3.1, accept single-quoted header values Signed-off-by: Todd Baert --- fm-provider-test-harness | 2 +- .../com/dynatrace/openfeature/e2e/E2eStepDefinitions.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fm-provider-test-harness b/fm-provider-test-harness index aad147b..ec236c7 160000 --- a/fm-provider-test-harness +++ b/fm-provider-test-harness @@ -1 +1 @@ -Subproject commit aad147bcf0d24ff00dfc268464492aeac5367095 +Subproject commit ec236c732f0fc8bb87cd56b077e04da51a28ed02 diff --git a/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java b/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java index 1c69121..1797b24 100644 --- a/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java +++ b/src/test/java/com/dynatrace/openfeature/e2e/E2eStepDefinitions.java @@ -183,11 +183,11 @@ public void theCdnServesTheFlagConfigurationAdvertisingAnSseStreamAtUrl(String f } // header and value are null when the step has no "with ... header ..." suffix - @Given("^an initialized, READY provider serving the \"([^\"]*)\" flag configuration(?: with \"([^\"]*)\" header \"([^\"]*)\")?$") + @Given("^an initialized, READY provider serving the \"([^\"]*)\" flag configuration(?: with \"([^\"]*)\" header (\"[^\"]*\"|'[^']*'))?$") public void anInitializedReadyProviderServingTheFlagConfiguration(String flagFileName, String header, String value) { sdkKey = BASE_KEY; CdnResponse response = CdnResponse.of(200, loadFlagConfig(flagFileName)); - setCdnResponses(header == null ? response : response.withHeader(header, value)); + setCdnResponses(header == null ? response : response.withHeader(header, unquote(value))); registerProviderWithSdk(); assertEquals(ProviderState.READY, client.getProviderState()); awaitSseSettled(); From ef24b466a6903377c56bb99881ea6c9f17119c7f Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 27 Oct 2026 09:44:23 -0400 Subject: [PATCH 08/12] fixup: use fast-poll mode if fetch fails while SSE connected Signed-off-by: Todd Baert --- .../java/com/dynatrace/openfeature/DynatraceProvider.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java index 45aa626..2d234d3 100644 --- a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java +++ b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java @@ -534,6 +534,9 @@ private void updateMetricsVersionFromFlagSet() { } private synchronized void onFetchSuccess() { + if (sseConnected.get()) { + pollingManager.setInterval(pollIntervalConnected); + } cancelGraceTimer(); ProviderState current = state.get(); if (current == ProviderState.STALE || current == ProviderState.ERROR) { @@ -547,6 +550,8 @@ private synchronized void onFetchSuccess() { } private synchronized void onFetchFailure(String reason) { + // fetch at the fast cadence until one succeeds, even with SSE connected + pollingManager.setInterval(pollIntervalDisconnected); if (state.compareAndSet(ProviderState.READY, ProviderState.STALE)) { log.warn("fetch failed, transitioning to STALE: {}", reason); emitProviderStale(ProviderEventDetails.builder() From 3b5eb0853130c9711eeaf54cb18557c21164cb3e Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 27 Oct 2026 10:46:54 -0400 Subject: [PATCH 09/12] fixup: only relax poll cadence on SSE connect if READY Signed-off-by: Todd Baert --- .../java/com/dynatrace/openfeature/DynatraceProvider.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java index 2d234d3..f6b57fd 100644 --- a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java +++ b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java @@ -447,8 +447,11 @@ synchronized void onSseConnected() { if (pm == null) { return; } - log.info("SSE connected; poll interval switched to {}", pollIntervalConnected); - pm.setInterval(pollIntervalConnected); + // stay at the fast cadence until a fetch succeeds + if (state.get() == ProviderState.READY) { + log.info("SSE connected; poll interval switched to {}", pollIntervalConnected); + pm.setInterval(pollIntervalConnected); + } // catch up on anything announced while the stream was down; never dropped pm.requestOutOfBandFetch(null); } From 398a6cba4b501b3c30e1238440424b22fe79b948 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 27 Oct 2026 11:18:18 -0400 Subject: [PATCH 10/12] fixup: relax poll cadence only on a successful fetch Signed-off-by: Todd Baert --- .../java/com/dynatrace/openfeature/DynatraceProvider.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java index f6b57fd..d83b598 100644 --- a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java +++ b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java @@ -447,11 +447,7 @@ synchronized void onSseConnected() { if (pm == null) { return; } - // stay at the fast cadence until a fetch succeeds - if (state.get() == ProviderState.READY) { - log.info("SSE connected; poll interval switched to {}", pollIntervalConnected); - pm.setInterval(pollIntervalConnected); - } + log.info("SSE connected"); // catch up on anything announced while the stream was down; never dropped pm.requestOutOfBandFetch(null); } From 3a1dead9d7bc42a711e4a04099c083a5ba314cdc Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 27 Oct 2026 14:06:27 -0400 Subject: [PATCH 11/12] fixup: bump harness to v1.4.0 Signed-off-by: Todd Baert --- fm-provider-test-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fm-provider-test-harness b/fm-provider-test-harness index ec236c7..e3fbb5d 160000 --- a/fm-provider-test-harness +++ b/fm-provider-test-harness @@ -1 +1 @@ -Subproject commit ec236c732f0fc8bb87cd56b077e04da51a28ed02 +Subproject commit e3fbb5d488d7f7ad51b885974a08f93b302cc4f6 From cd94c614eaa9127307442a44cc2e896f4795a77b Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 28 Oct 2026 12:28:19 -0400 Subject: [PATCH 12/12] fixup: start SSE after polling mgr Signed-off-by: Todd Baert --- .../openfeature/DynatraceProvider.java | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java index d83b598..37bdead 100644 --- a/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java +++ b/src/main/java/com/dynatrace/openfeature/DynatraceProvider.java @@ -248,16 +248,17 @@ public List getProviderHooks() { } /** - * Fetches the initial configuration from the CDN, opens the SSE connection, starts polling, + * Fetches the initial configuration from the CDN, starts polling, opens the SSE connection, * and starts the evaluation-metrics flusher. * *

    Initialization order (per spec 3.1): *

      *
    1. Fetch flagd config JSON from CDN
    2. *
    3. Load config into flagd-core
    4. - *
    5. Open SSE connection (async; errors handled by callbacks)
    6. *
    7. Start polling timer, only once step 1 has completed, so the initial fetch never * contends with the first tick (spec 2.1.1)
    8. + *
    9. Open SSE connection (async; errors handled by callbacks), only once polling has + * started, so the catch-up fetch on connect has a manager to run on
    10. *
    11. Emit {@code PROVIDER_READY} (via return)
    12. *
    * @@ -283,6 +284,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { }); RuntimeException initFailure = null; + String initialConfig = null; try { FetchResult result; try { @@ -311,9 +313,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { } state.set(ProviderState.READY); - - // open SSE connection (spec 3.1 step 3) - ensureSseStarted(body); + initialConfig = body; } catch (RuntimeException e) { // Don't rethrow yet: polling below recovers, and invalidating stops a phantom 304 // from "recovering" to READY with no flags loaded. @@ -334,13 +334,12 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { this::onFetchFailure, pollIntervalDisconnected); pollingManager.start(); - // If SSE connected during init (before the manager existed), adopt the connected cadence. - if (sseConnected.get()) { - pollingManager.setInterval(pollIntervalConnected); - } - metricsFlusher.start(); + if (initialConfig != null) { + ensureSseStarted(initialConfig); + } + if (initFailure != null) { throw initFailure; }