From 141146c40299668887c737578e501aa32e6ca166 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 5 Aug 2026 16:18:04 -0400 Subject: [PATCH 1/2] feat(openfeature): emit provider events from the DevCycle config lifecycle --- OpenFeature.md | 29 +++ .../server/local/api/DevCycleLocalClient.java | 41 +++- .../local/managers/ConfigUpdateListener.java | 29 +++ .../managers/EnvironmentConfigManager.java | 47 ++++- .../server/openfeature/DevCycleProvider.java | 158 ++++++++++++++- .../sdk/server/helpers/LocalConfigServer.java | 26 ++- .../DevCycleProviderEventsTest.java | 186 ++++++++++++++++++ .../openfeature/DevCycleProviderTest.java | 24 +++ 8 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/devcycle/sdk/server/local/managers/ConfigUpdateListener.java create mode 100644 src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java diff --git a/OpenFeature.md b/OpenFeature.md index f41283e8..16043c31 100644 --- a/OpenFeature.md +++ b/OpenFeature.md @@ -65,6 +65,35 @@ added to the `customData` property of the `DevCycleUser`. DevCycle allows the following data types for custom data values: **boolean**, **integer**, **double**, **float**, and **String**. Other data types will be ignored +### Provider Events + +The provider emits [OpenFeature provider events](https://openfeature.dev/specification/sections/events), so +applications can react to configuration changes and to the provider losing its connection to DevCycle. + +```java +Client openFeatureClient = api.getClient(); + +openFeatureClient.onProviderConfigurationChanged(details -> + System.out.println("DevCycle config updated, ETag " + details.getEventMetadata().getString("configETag"))); +openFeatureClient.onProviderStale(details -> + System.out.println("DevCycle config could not be refreshed: " + details.getMessage())); +``` + +| Event | When it is emitted | +| --- | --- | +| `PROVIDER_READY` | The DevCycle client has loaded a configuration. Also emitted when config fetching recovers after a failure. | +| `PROVIDER_CONFIGURATION_CHANGED` | A newly fetched configuration differs from the one previously in use, either from polling or from a realtime update. | +| `PROVIDER_STALE` | A configuration fetch failed while a previously fetched configuration is still being served. Evaluations continue against that cached configuration. | +| `PROVIDER_ERROR` | A configuration fetch failed and no configuration has ever been loaded. Reported with error code `PROVIDER_FATAL` when the SDK key is unauthorized, which means the provider will not recover. | + +`PROVIDER_CONFIGURATION_CHANGED` does not include a `flagsChanged` list. DevCycle resolves variables per-user at +evaluation time, so the set of variable keys whose value actually changed for a given user is not known when the +configuration is fetched. + +Only the Local Bucketing client (`DevCycleLocalClient`) holds a configuration, so configuration change, stale, and +error events apply to it. The Cloud Bucketing client (`DevCycleCloudClient`) evaluates against the DevCycle API on +every request and becomes ready immediately. + ### JSON Flag Limitations The OpenFeature spec for JSON flags allows for any type of valid JSON value to be set as the flag value. diff --git a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java index 43c2a57c..14e676df 100755 --- a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java +++ b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java @@ -8,6 +8,7 @@ import com.devcycle.sdk.server.common.api.IDevCycleClient; import com.devcycle.sdk.server.common.exception.BeforeHookError; +import com.devcycle.sdk.server.common.exception.DevCycleException; import com.devcycle.sdk.server.common.logging.DevCycleLogger; import com.devcycle.sdk.server.common.model.BaseVariable; import com.devcycle.sdk.server.common.model.DevCycleEvent; @@ -22,6 +23,7 @@ import com.devcycle.sdk.server.common.model.Variable; import com.devcycle.sdk.server.common.model.Variable.TypeEnum; import com.devcycle.sdk.server.local.bucketing.LocalBucketing; +import com.devcycle.sdk.server.local.managers.ConfigUpdateListener; import com.devcycle.sdk.server.local.managers.EnvironmentConfigManager; import com.devcycle.sdk.server.local.managers.EventQueueManager; import com.devcycle.sdk.server.local.model.BucketedUserConfig; @@ -48,6 +50,34 @@ public final class DevCycleLocalClient implements IDevCycleClient { // raw type here is okay because we're using a generic type for the variable private EvalHooksRunner evalHooksRunner; + /** + * Forwards config lifecycle callbacks to the OpenFeature provider, if one has been created, so + * it can emit the corresponding provider events. Invoked on the config polling and SSE threads. + */ + private final ConfigUpdateListener configUpdateListener = new ConfigUpdateListener() { + @Override + public void onConfigLoaded(String etag, boolean firstLoad, boolean changed) { + DevCycleProvider provider = openFeatureProvider; + if (provider != null) { + provider.onConfigLoaded(etag, firstLoad, changed); + } + } + + @Override + public void onConfigError(DevCycleException error, boolean fatal) { + if (fatal) { + // retained so it can be replayed to a provider created after the failure + fatalConfigError = error; + } + DevCycleProvider provider = openFeatureProvider; + if (provider != null) { + provider.onConfigError(error, fatal); + } + } + }; + + private volatile DevCycleException fatalConfigError; + public DevCycleLocalClient(String sdkKey) { this(sdkKey, DevCycleLocalOptions.builder().build()); } @@ -71,7 +101,7 @@ public DevCycleLocalClient(String sdkKey, DevCycleLocalOptions dvcOptions) { localBucketing.setPlatformData(PlatformData.builder().build().toString()); - configManager = new EnvironmentConfigManager(sdkKey, localBucketing, dvcOptions); + configManager = new EnvironmentConfigManager(sdkKey, localBucketing, dvcOptions, configUpdateListener); this.sdkKey = sdkKey; try { eventQueueManager = new EventQueueManager(sdkKey, localBucketing, clientUUID, dvcOptions); @@ -325,7 +355,8 @@ public void clearHooks() { this.evalHooksRunner.clearHooks(); } - private DevCycleProvider openFeatureProvider; + // volatile: read from the config polling and SSE threads via configUpdateListener + private volatile DevCycleProvider openFeatureProvider; /** * @return the OpenFeature provider for this client. @@ -336,6 +367,12 @@ public synchronized FeatureProvider getOpenFeatureProvider() { localBucketing.setPlatformData(platformData.toString()); if (openFeatureProvider == null) { openFeatureProvider = new DevCycleProvider(this); + DevCycleException fatal = fatalConfigError; + if (fatal != null) { + // the config failed fatally before this provider existed, so replay it rather than + // leaving the provider to wait out its initialization timeout + openFeatureProvider.onConfigError(fatal, true); + } } return openFeatureProvider; } diff --git a/src/main/java/com/devcycle/sdk/server/local/managers/ConfigUpdateListener.java b/src/main/java/com/devcycle/sdk/server/local/managers/ConfigUpdateListener.java new file mode 100644 index 00000000..6f70a16a --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/local/managers/ConfigUpdateListener.java @@ -0,0 +1,29 @@ +package com.devcycle.sdk.server.local.managers; + +import com.devcycle.sdk.server.common.exception.DevCycleException; + +/** + * Callback for the lifecycle of the locally cached project configuration. + *

+ * Implementations are invoked on the config polling thread, or on the SSE message thread when a + * realtime update triggers a refetch, and must not block. + */ +public interface ConfigUpdateListener { + + /** + * A config fetch completed successfully. + * + * @param etag ETag of the config now in use + * @param firstLoad true if this is the first config that has been loaded + * @param changed true if the fetched config differs from the previously stored one + */ + void onConfigLoaded(String etag, boolean firstLoad, boolean changed); + + /** + * A config fetch failed. A previously fetched config, if any, remains in use. + * + * @param error the failure + * @param fatal true if the failure is unrecoverable, ie. the SDK key is unauthorized + */ + void onConfigError(DevCycleException error, boolean fatal); +} diff --git a/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java b/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java index bf14bae2..197facb2 100644 --- a/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java +++ b/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java @@ -23,6 +23,7 @@ import java.net.URISyntaxException; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Objects; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -37,6 +38,7 @@ public final class EnvironmentConfigManager { private SSEManager sseManager; private boolean isSSEConnected = false; private final DevCycleLocalOptions options; + private final ConfigUpdateListener configUpdateListener; private ProjectConfig config; private String configETag = ""; @@ -49,9 +51,15 @@ public final class EnvironmentConfigManager { private boolean pollingEnabled = true; public EnvironmentConfigManager(String sdkKey, LocalBucketing localBucketing, DevCycleLocalOptions options) { + this(sdkKey, localBucketing, options, null); + } + + public EnvironmentConfigManager(String sdkKey, LocalBucketing localBucketing, DevCycleLocalOptions options, + ConfigUpdateListener configUpdateListener) { this.sdkKey = sdkKey; this.localBucketing = localBucketing; this.options = options; + this.configUpdateListener = configUpdateListener; configApiClient = new DevCycleLocalApiClient(sdkKey, options).initialize(); @@ -75,7 +83,8 @@ public void run() { } } catch (DevCycleException e) { DevCycleLogger.error("Failed to load config: " + e.getMessage(), e); - } + notifyConfigError(e); + } } }; @@ -83,11 +92,19 @@ public boolean isConfigInitialized() { return config != null; } - private ProjectConfig getConfig() throws DevCycleException { + private ProjectConfig getConfig() throws DevCycleException { + boolean firstLoad = this.config == null; + String previousETag = this.configETag; + Call config = this.configApiClient.getConfig(this.sdkKey, this.configETag, this.configLastModified); ProjectConfig fetchedConfig = getResponseWithRetries(config, 1); this.config = fetchedConfig; - + + if (this.config != null) { + // a 304, or a config older than the one already stored, leaves the ETag untouched + notifyConfigLoaded(firstLoad, !Objects.equals(previousETag, this.configETag)); + } + if (!this.options.isDisableRealtimeUpdates() && this.config != null && this.config.getSse() != null) { try { URI uri = new URI(this.config.getSse().getHostname() + this.config.getSse().getPath()); @@ -102,6 +119,30 @@ private ProjectConfig getConfig() throws DevCycleException { return this.config; } + private void notifyConfigLoaded(boolean firstLoad, boolean changed) { + if (configUpdateListener == null) { + return; + } + try { + configUpdateListener.onConfigLoaded(this.configETag, firstLoad, changed); + } catch (Exception e) { + DevCycleLogger.warning("Config update listener threw an exception: " + e.getMessage()); + } + } + + private void notifyConfigError(DevCycleException error) { + if (configUpdateListener == null) { + return; + } + HttpResponseCode responseCode = error.getHttpResponseCode(); + boolean fatal = responseCode == HttpResponseCode.UNAUTHORIZED || responseCode == HttpResponseCode.FORBIDDEN; + try { + configUpdateListener.onConfigError(error, fatal); + } catch (Exception e) { + DevCycleLogger.warning("Config update listener threw an exception: " + e.getMessage()); + } + } + private Void handleSSEMessage(MessageEvent messageEvent) { DevCycleLogger.debug("Received message: " + messageEvent.getData()); if (!isSSEConnected) diff --git a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java index 07cb9d2c..bfb7ae0e 100644 --- a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java +++ b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java @@ -3,36 +3,74 @@ import java.math.BigDecimal; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import com.devcycle.sdk.server.common.api.IDevCycleClient; import com.devcycle.sdk.server.common.exception.DevCycleException; +import com.devcycle.sdk.server.common.logging.DevCycleLogger; import com.devcycle.sdk.server.common.model.DevCycleEvent; import com.devcycle.sdk.server.common.model.DevCycleUser; import com.devcycle.sdk.server.common.model.EvalReason; import com.devcycle.sdk.server.common.model.Variable; +import com.devcycle.sdk.server.local.managers.ConfigUpdateListener; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.EventProvider; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ImmutableMetadata.ImmutableMetadataBuilder; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.TrackingEventDetails; import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.GeneralError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import dev.openfeature.sdk.exceptions.TypeMismatchError; -public class DevCycleProvider implements FeatureProvider { +public class DevCycleProvider extends EventProvider implements ConfigUpdateListener { private static final String PROVIDER_NAME = "DevCycle"; + private static final long DEFAULT_INIT_TIMEOUT_MS = 2000; private final IDevCycleClient devcycleClient; + private final long initTimeoutMS; + + /** + * Released once the client has a config to serve, or once we know it never will. + */ + private final CountDownLatch initialConfigLatch = new CountDownLatch(1); + + private final Object stateLock = new Object(); + + /** + * True while the last config fetch attempt was a failure, so recovery is reported once rather + * than emitting a duplicate event on every failed poll. Guarded by {@link #stateLock}. + */ + private boolean degraded; + + /** + * True once {@link #initialize(EvaluationContext)} has failed. The SDK will not call it again, + * so a later successful fetch has to emit PROVIDER_READY itself. Guarded by {@link #stateLock}. + */ + private boolean initializeFailed; + + /** + * Set when the config can never be fetched, ie. the SDK key is unauthorized. Guarded by + * {@link #stateLock}. + */ + private DevCycleException fatalError; public DevCycleProvider(IDevCycleClient devcycleClient) { + this(devcycleClient, DEFAULT_INIT_TIMEOUT_MS); + } + + DevCycleProvider(IDevCycleClient devcycleClient, long initTimeoutMS) { this.devcycleClient = devcycleClient; + this.initTimeoutMS = initTimeoutMS; } @Override @@ -40,28 +78,130 @@ public Metadata getMetadata() { return () -> PROVIDER_NAME + " " + devcycleClient.getSDKPlatform(); } + /** + * The OpenFeature SDK emits PROVIDER_READY when this returns and PROVIDER_ERROR when it throws, + * so this method never emits those events itself. Throwing a {@link FatalError} tells the SDK + * the provider is not recoverable, which is the right signal for an unauthorized SDK key. + */ @Override public void initialize(EvaluationContext evaluationContext) throws Exception { if (devcycleClient.isInitialized()) { return; } - long deadline = 2 * 1000; // Delay in milliseconds - long start = System.currentTimeMillis(); + initialConfigLatch.await(initTimeoutMS, TimeUnit.MILLISECONDS); - do { - if (deadline <= System.currentTimeMillis() - start) { - throw new GeneralError("DevCycle client not initialized within 2 seconds"); + synchronized (stateLock) { + if (fatalError != null) { + throw new FatalError("DevCycle client cannot be initialized: " + fatalError.getMessage()); } - Thread.sleep(5); - } while (!devcycleClient.isInitialized()); + + if (!devcycleClient.isInitialized()) { + initializeFailed = true; + throw new GeneralError("DevCycle client not initialized within " + initTimeoutMS + "ms"); + } + } } @Override public void shutdown() { + // drains the event emitter executor owned by EventProvider + super.shutdown(); devcycleClient.close(); } + /** + * Called by the DevCycle Local client when a config fetch succeeds. Not intended to be called + * directly. + */ + @Override + public void onConfigLoaded(String configETag, boolean firstLoad, boolean changed) { + boolean recovered; + synchronized (stateLock) { + recovered = degraded || initializeFailed; + degraded = false; + initializeFailed = false; + if (firstLoad) { + initialConfigLatch.countDown(); + } + } + + if (recovered) { + // clears the ERROR or STALE state the SDK recorded for the earlier failure. Not needed + // on a clean first load, where the SDK emits PROVIDER_READY once initialize() returns + emitProviderReady(ProviderEventDetails.builder() + .message("DevCycle config fetching has recovered") + .eventMetadata(configEventMetadata(configETag)) + .build()); + } + + if (changed && !firstLoad) { + // flagsChanged is intentionally left unset: DevCycle resolves variables per-user at + // evaluation time, so the set of keys whose value changed is not knowable here + emitProviderConfigurationChanged(ProviderEventDetails.builder() + .message("DevCycle config was updated") + .eventMetadata(configEventMetadata(configETag)) + .build()); + } + } + + /** + * Called by the DevCycle Local client when a config fetch fails. Not intended to be called + * directly. + */ + @Override + public void onConfigError(DevCycleException error, boolean fatal) { + boolean report; + synchronized (stateLock) { + if (fatal) { + if (fatalError != null) { + // already reported, the SDK is holding the provider in the FATAL state + return; + } + fatalError = error; + // unblocks initialize() so it fails immediately rather than waiting out the timeout + initialConfigLatch.countDown(); + } + report = fatal || !degraded; + degraded = true; + } + + if (fatal) { + emitProviderError(ProviderEventDetails.builder() + .errorCode(ErrorCode.PROVIDER_FATAL) + .message(error.getMessage()) + .build()); + return; + } + + if (!report) { + // already reported, don't emit an event for every subsequent failed poll + DevCycleLogger.debug("DevCycle config fetch still failing: " + error.getMessage()); + return; + } + + if (devcycleClient.isInitialized()) { + // a previously fetched config is still being served, so evaluations remain usable + emitProviderStale(ProviderEventDetails.builder() + .message("DevCycle config could not be refreshed, serving the last known config: " + + error.getMessage()) + .build()); + } else { + emitProviderError(ProviderEventDetails.builder() + .errorCode(ErrorCode.GENERAL) + .message(error.getMessage()) + .build()); + } + } + + private ImmutableMetadata configEventMetadata(String configETag) { + ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + if (configETag != null && !configETag.isEmpty()) { + builder.addString("configETag", configETag); + } + return builder.build(); + } + @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { return resolvePrimitiveVariable(key, defaultValue, ctx); diff --git a/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java b/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java index 382259ed..c5bb05af 100644 --- a/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java +++ b/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java @@ -13,6 +13,8 @@ public class LocalConfigServer { private final HttpServer server; private String configData = ""; + private String etag = "\"test-etag-12345\""; + private int responseCode = 200; public LocalConfigServer(String configData, int port) throws IOException { this.configData = configData; @@ -28,11 +30,17 @@ public String getHostRootURL() { } public void handleConfigRequest(HttpExchange exchange) throws IOException { + if (responseCode != 200) { + exchange.sendResponseHeaders(responseCode, -1); + exchange.close(); + return; + } + // Add required headers for ConfigMetadata creation String currentTime = ZonedDateTime.now().format(DateTimeFormatter.RFC_1123_DATE_TIME); - exchange.getResponseHeaders().set("ETag", "\"test-etag-12345\""); + exchange.getResponseHeaders().set("ETag", etag); exchange.getResponseHeaders().set("Last-Modified", currentTime); - + byte[] responseData = configData.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, responseData.length); OutputStream outputStream = exchange.getResponseBody(); @@ -45,6 +53,20 @@ public void setConfigData(String configData) { this.configData = configData; } + /** + * Change the ETag served with the config, so a subsequent poll is seen as a new config. + */ + public void setETag(String etag) { + this.etag = etag; + } + + /** + * Serve the given status code with an empty body instead of the config. + */ + public void setResponseCode(int responseCode) { + this.responseCode = responseCode; + } + public void start() { this.server.start(); } diff --git a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java new file mode 100644 index 00000000..1dabeafc --- /dev/null +++ b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java @@ -0,0 +1,186 @@ +package com.devcycle.sdk.server.openfeature; + +import com.devcycle.sdk.server.helpers.LocalConfigServer; +import com.devcycle.sdk.server.helpers.TestDataFixtures; +import com.devcycle.sdk.server.local.api.DevCycleLocalClient; +import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; +import dev.openfeature.sdk.EventDetails; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.exceptions.FatalError; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Integration tests for the provider events emitted off the back of the Local SDK config lifecycle + */ +public class DevCycleProviderEventsTest { + private static final int PORT = 9010; + private static final int POLL_INTERVAL_MS = 1000; + private static final int EVENT_TIMEOUT_SECONDS = 20; + + private final String apiKey = String.format("server-%s", UUID.randomUUID()); + private final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + + private LocalConfigServer localConfigServer; + private DevCycleLocalClient client; + + @Before + public void setup() throws Exception { + localConfigServer = new LocalConfigServer(TestDataFixtures.SmallConfig(), PORT); + localConfigServer.start(); + } + + @After + public void cleanup() { + if (client != null) { + client.close(); + } + localConfigServer.stop(); + } + + private DevCycleLocalClient createClient() { + DevCycleLocalOptions options = DevCycleLocalOptions.builder() + .configCdnBaseUrl(localConfigServer.getHostRootURL()) + .configPollingIntervalMS(POLL_INTERVAL_MS) + .disableRealtimeUpdates(true) + .build(); + return new DevCycleLocalClient(apiKey, options); + } + + private DevCycleLocalClient createInitializedClient() throws InterruptedException { + DevCycleLocalClient client = createClient(); + long deadline = System.currentTimeMillis() + 10000; + while (!client.isInitialized()) { + if (System.currentTimeMillis() > deadline) { + throw new IllegalStateException("Client failed to initialize in 10 seconds"); + } + Thread.sleep(50); + } + return client; + } + + @Test + public void testEmitsConfigurationChangedWhenConfigIsUpdated() throws Exception { + client = createInitializedClient(); + FeatureProvider provider = client.getOpenFeatureProvider(); + + String domain = "config-changed-" + UUID.randomUUID(); + api.setProviderAndWait(domain, provider); + + CountDownLatch configurationChanged = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + api.getClient(domain).onProviderConfigurationChanged(details -> { + received.set(details); + configurationChanged.countDown(); + }); + + localConfigServer.setETag("\"test-etag-updated\""); + + Assert.assertTrue( + "expected PROVIDER_CONFIGURATION_CHANGED after the config ETag changed", + configurationChanged.await(EVENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + Assert.assertEquals( + "\"test-etag-updated\"", + received.get().getEventMetadata().getString("configETag")); + // DevCycle resolves variables per-user, so no flag key list is reported + Assert.assertNull(received.get().getFlagsChanged()); + } + + @Test + public void testDoesNotEmitConfigurationChangedWhenConfigIsUnchanged() throws Exception { + client = createInitializedClient(); + FeatureProvider provider = client.getOpenFeatureProvider(); + + String domain = "config-unchanged-" + UUID.randomUUID(); + api.setProviderAndWait(domain, provider); + + CountDownLatch configurationChanged = new CountDownLatch(1); + api.getClient(domain).onProviderConfigurationChanged(details -> configurationChanged.countDown()); + + // the config server keeps serving the same ETag, so several polls happen with no change + Assert.assertFalse( + "expected no PROVIDER_CONFIGURATION_CHANGED while the config ETag is unchanged", + configurationChanged.await(POLL_INTERVAL_MS * 3L, TimeUnit.MILLISECONDS)); + } + + @Test + public void testEmitsStaleWhenConfigRefreshFailsThenReadyOnRecovery() throws Exception { + client = createInitializedClient(); + FeatureProvider provider = client.getOpenFeatureProvider(); + + String domain = "stale-" + UUID.randomUUID(); + api.setProviderAndWait(domain, provider); + + CountDownLatch stale = new CountDownLatch(1); + api.getClient(domain).onProviderStale(details -> stale.countDown()); + + localConfigServer.setResponseCode(500); + + Assert.assertTrue( + "expected PROVIDER_STALE when the config could not be refreshed", + stale.await(EVENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + // the cached config is still served while stale + Assert.assertTrue(client.isInitialized()); + + // registered only now: a handler added while the provider is already READY fires immediately + CountDownLatch ready = new CountDownLatch(1); + api.getClient(domain).onProviderReady(details -> ready.countDown()); + + localConfigServer.setResponseCode(200); + localConfigServer.setETag("\"test-etag-recovered\""); + + Assert.assertTrue( + "expected PROVIDER_READY once config fetching recovered", + ready.await(EVENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + + @Test + public void testEmitsReadyWhenConfigArrivesAfterInitializationFailed() throws Exception { + localConfigServer.setResponseCode(500); + + client = createClient(); + FeatureProvider provider = client.getOpenFeatureProvider(); + + String domain = "late-config-" + UUID.randomUUID(); + CountDownLatch errored = new CountDownLatch(1); + api.setProvider(domain, provider); + api.getClient(domain).onProviderError(details -> errored.countDown()); + + Assert.assertTrue( + "expected PROVIDER_ERROR when no config could be fetched", + errored.await(EVENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + // the SDK does not call initialize() again, so the provider has to report readiness itself + CountDownLatch ready = new CountDownLatch(1); + api.getClient(domain).onProviderReady(details -> ready.countDown()); + + localConfigServer.setResponseCode(200); + + Assert.assertTrue( + "expected PROVIDER_READY once a config was finally fetched", + ready.await(EVENT_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + Assert.assertTrue(client.isInitialized()); + } + + @Test + public void testInitializeThrowsFatalErrorWhenSDKKeyIsUnauthorized() throws Exception { + localConfigServer.setResponseCode(401); + + client = createClient(); + FeatureProvider provider = client.getOpenFeatureProvider(); + + Assert.assertThrows( + FatalError.class, + () -> provider.initialize(new ImmutableContext("test-1234"))); + } +} diff --git a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java index 51de0a35..8251757f 100644 --- a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java +++ b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java @@ -2,6 +2,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; @@ -25,6 +26,7 @@ import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; import dev.openfeature.sdk.exceptions.TypeMismatchError; @@ -379,4 +381,26 @@ public void testGetObjectEvaluationWithDevCycleEvalReason() { Assert.assertEquals(result.getFlagMetadata().getString("evalReasonDetails"), "User ID"); Assert.assertEquals(result.getFlagMetadata().getString("evalReasonTargetId"), "json_target_id"); } + + @Test + public void testShutdownClosesClient() { + IDevCycleClient dvcClient = mock(IDevCycleClient.class); + DevCycleProvider provider = new DevCycleProvider(dvcClient); + + // EventProvider.shutdown() is concrete, so the override has to close the client as well as + // draining the emitter executor via super.shutdown() + provider.shutdown(); + + verify(dvcClient).close(); + } + + @Test + public void testInitializeThrowsWhenClientNeverInitializes() { + IDevCycleClient dvcClient = mock(IDevCycleClient.class); + when(dvcClient.isInitialized()).thenReturn(false); + + DevCycleProvider provider = new DevCycleProvider(dvcClient, 50); + + Assert.assertThrows(GeneralError.class, () -> provider.initialize(new ImmutableContext("test-1234"))); + } } From 37d447bc04ae864155c531defa7fba08ca3645e6 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 7 Aug 2026 15:36:12 -0400 Subject: [PATCH 2/2] refactor(openfeature): register the provider as a config update listener --- .../server/local/api/DevCycleLocalClient.java | 43 ++------------ .../managers/EnvironmentConfigManager.java | 57 +++++++++++++------ .../server/openfeature/DevCycleProvider.java | 8 ++- .../sdk/server/helpers/LocalConfigServer.java | 7 ++- .../DevCycleProviderEventsTest.java | 3 +- 5 files changed, 56 insertions(+), 62 deletions(-) diff --git a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java index 14e676df..ebb63225 100755 --- a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java +++ b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java @@ -8,7 +8,6 @@ import com.devcycle.sdk.server.common.api.IDevCycleClient; import com.devcycle.sdk.server.common.exception.BeforeHookError; -import com.devcycle.sdk.server.common.exception.DevCycleException; import com.devcycle.sdk.server.common.logging.DevCycleLogger; import com.devcycle.sdk.server.common.model.BaseVariable; import com.devcycle.sdk.server.common.model.DevCycleEvent; @@ -23,7 +22,6 @@ import com.devcycle.sdk.server.common.model.Variable; import com.devcycle.sdk.server.common.model.Variable.TypeEnum; import com.devcycle.sdk.server.local.bucketing.LocalBucketing; -import com.devcycle.sdk.server.local.managers.ConfigUpdateListener; import com.devcycle.sdk.server.local.managers.EnvironmentConfigManager; import com.devcycle.sdk.server.local.managers.EventQueueManager; import com.devcycle.sdk.server.local.model.BucketedUserConfig; @@ -50,34 +48,6 @@ public final class DevCycleLocalClient implements IDevCycleClient { // raw type here is okay because we're using a generic type for the variable private EvalHooksRunner evalHooksRunner; - /** - * Forwards config lifecycle callbacks to the OpenFeature provider, if one has been created, so - * it can emit the corresponding provider events. Invoked on the config polling and SSE threads. - */ - private final ConfigUpdateListener configUpdateListener = new ConfigUpdateListener() { - @Override - public void onConfigLoaded(String etag, boolean firstLoad, boolean changed) { - DevCycleProvider provider = openFeatureProvider; - if (provider != null) { - provider.onConfigLoaded(etag, firstLoad, changed); - } - } - - @Override - public void onConfigError(DevCycleException error, boolean fatal) { - if (fatal) { - // retained so it can be replayed to a provider created after the failure - fatalConfigError = error; - } - DevCycleProvider provider = openFeatureProvider; - if (provider != null) { - provider.onConfigError(error, fatal); - } - } - }; - - private volatile DevCycleException fatalConfigError; - public DevCycleLocalClient(String sdkKey) { this(sdkKey, DevCycleLocalOptions.builder().build()); } @@ -101,7 +71,7 @@ public DevCycleLocalClient(String sdkKey, DevCycleLocalOptions dvcOptions) { localBucketing.setPlatformData(PlatformData.builder().build().toString()); - configManager = new EnvironmentConfigManager(sdkKey, localBucketing, dvcOptions, configUpdateListener); + configManager = new EnvironmentConfigManager(sdkKey, localBucketing, dvcOptions); this.sdkKey = sdkKey; try { eventQueueManager = new EventQueueManager(sdkKey, localBucketing, clientUUID, dvcOptions); @@ -355,8 +325,7 @@ public void clearHooks() { this.evalHooksRunner.clearHooks(); } - // volatile: read from the config polling and SSE threads via configUpdateListener - private volatile DevCycleProvider openFeatureProvider; + private DevCycleProvider openFeatureProvider; /** * @return the OpenFeature provider for this client. @@ -367,12 +336,8 @@ public synchronized FeatureProvider getOpenFeatureProvider() { localBucketing.setPlatformData(platformData.toString()); if (openFeatureProvider == null) { openFeatureProvider = new DevCycleProvider(this); - DevCycleException fatal = fatalConfigError; - if (fatal != null) { - // the config failed fatally before this provider existed, so replay it rather than - // leaving the provider to wait out its initialization timeout - openFeatureProvider.onConfigError(fatal, true); - } + // the provider listens for config updates itself so it can emit provider events + configManager.addConfigUpdateListener(openFeatureProvider); } return openFeatureProvider; } diff --git a/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java b/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java index 197facb2..7bf00a63 100644 --- a/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java +++ b/src/main/java/com/devcycle/sdk/server/local/managers/EnvironmentConfigManager.java @@ -23,10 +23,13 @@ import java.net.URISyntaxException; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; public final class EnvironmentConfigManager { private static final ObjectMapper OBJECT_MAPPER = ObjectMapperUtils.createDefaultObjectMapper(); @@ -38,7 +41,16 @@ public final class EnvironmentConfigManager { private SSEManager sseManager; private boolean isSSEConnected = false; private final DevCycleLocalOptions options; - private final ConfigUpdateListener configUpdateListener; + + /** + * Copy-on-write so the polling and SSE threads can iterate while a listener is being added. + */ + private final List configUpdateListeners = new CopyOnWriteArrayList<>(); + + /** + * Retained so a listener registered after an unrecoverable failure still learns about it. + */ + private volatile DevCycleException fatalConfigError; private ProjectConfig config; private String configETag = ""; @@ -51,15 +63,9 @@ public final class EnvironmentConfigManager { private boolean pollingEnabled = true; public EnvironmentConfigManager(String sdkKey, LocalBucketing localBucketing, DevCycleLocalOptions options) { - this(sdkKey, localBucketing, options, null); - } - - public EnvironmentConfigManager(String sdkKey, LocalBucketing localBucketing, DevCycleLocalOptions options, - ConfigUpdateListener configUpdateListener) { this.sdkKey = sdkKey; this.localBucketing = localBucketing; this.options = options; - this.configUpdateListener = configUpdateListener; configApiClient = new DevCycleLocalApiClient(sdkKey, options).initialize(); @@ -119,25 +125,40 @@ private ProjectConfig getConfig() throws DevCycleException { return this.config; } - private void notifyConfigLoaded(boolean firstLoad, boolean changed) { - if (configUpdateListener == null) { - return; + /** + * Register a listener for the config lifecycle. If the config has already failed + * unrecoverably, the listener is told immediately rather than waiting for the next attempt. + */ + public void addConfigUpdateListener(ConfigUpdateListener listener) { + configUpdateListeners.add(listener); + + DevCycleException fatal = fatalConfigError; + if (fatal != null) { + notifyListener(listener, l -> l.onConfigError(fatal, true)); } - try { - configUpdateListener.onConfigLoaded(this.configETag, firstLoad, changed); - } catch (Exception e) { - DevCycleLogger.warning("Config update listener threw an exception: " + e.getMessage()); + } + + private void notifyConfigLoaded(boolean firstLoad, boolean changed) { + for (ConfigUpdateListener listener : configUpdateListeners) { + notifyListener(listener, l -> l.onConfigLoaded(this.configETag, firstLoad, changed)); } } private void notifyConfigError(DevCycleException error) { - if (configUpdateListener == null) { - return; - } HttpResponseCode responseCode = error.getHttpResponseCode(); boolean fatal = responseCode == HttpResponseCode.UNAUTHORIZED || responseCode == HttpResponseCode.FORBIDDEN; + if (fatal) { + fatalConfigError = error; + } + + for (ConfigUpdateListener listener : configUpdateListeners) { + notifyListener(listener, l -> l.onConfigError(error, fatal)); + } + } + + private void notifyListener(ConfigUpdateListener listener, Consumer notification) { try { - configUpdateListener.onConfigError(error, fatal); + notification.accept(listener); } catch (Exception e) { DevCycleLogger.warning("Config update listener threw an exception: " + e.getMessage()); } diff --git a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java index bfb7ae0e..b3a27d08 100644 --- a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java +++ b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java @@ -89,7 +89,13 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { return; } - initialConfigLatch.await(initTimeoutMS, TimeUnit.MILLISECONDS); + try { + initialConfigLatch.await(initTimeoutMS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + // await() clears the interrupt flag, restore it so callers can still see the interrupt + Thread.currentThread().interrupt(); + throw e; + } synchronized (stateLock) { if (fatalError != null) { diff --git a/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java b/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java index c5bb05af..9c2a871b 100644 --- a/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java +++ b/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java @@ -12,9 +12,10 @@ public class LocalConfigServer { private final HttpServer server; - private String configData = ""; - private String etag = "\"test-etag-12345\""; - private int responseCode = 200; + // volatile: written by the test thread, read by the HttpServer handler thread + private volatile String configData = ""; + private volatile String etag = "\"test-etag-12345\""; + private volatile int responseCode = 200; public LocalConfigServer(String configData, int port) throws IOException { this.configData = configData; diff --git a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java index 1dabeafc..5ea9c7e3 100644 --- a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java +++ b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java @@ -23,7 +23,8 @@ * Integration tests for the provider events emitted off the back of the Local SDK config lifecycle */ public class DevCycleProviderEventsTest { - private static final int PORT = 9010; + // port 0 lets the OS pick a free ephemeral port, getHostRootURL() reports the one chosen + private static final int PORT = 0; private static final int POLL_INTERVAL_MS = 1000; private static final int EVENT_TIMEOUT_SECONDS = 20;