diff --git a/OpenFeature.md b/OpenFeature.md
index f41283e..16043c3 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 43c2a57..ebb6322 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
@@ -336,6 +336,8 @@ public synchronized FeatureProvider getOpenFeatureProvider() {
localBucketing.setPlatformData(platformData.toString());
if (openFeatureProvider == null) {
openFeatureProvider = new DevCycleProvider(this);
+ // 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/ConfigUpdateListener.java b/src/main/java/com/devcycle/sdk/server/local/managers/ConfigUpdateListener.java
new file mode 100644
index 0000000..6f70a16
--- /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 bf14bae..7bf00a6 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,9 +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,6 +42,16 @@ public final class EnvironmentConfigManager {
private boolean isSSEConnected = false;
private final DevCycleLocalOptions options;
+ /**
+ * 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 = "";
private String configLastModified = "";
@@ -75,7 +89,8 @@ public void run() {
}
} catch (DevCycleException e) {
DevCycleLogger.error("Failed to load config: " + e.getMessage(), e);
- }
+ notifyConfigError(e);
+ }
}
};
@@ -83,11 +98,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 +125,45 @@ private ProjectConfig getConfig() throws DevCycleException {
return this.config;
}
+ /**
+ * 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));
+ }
+ }
+
+ 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) {
+ 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 {
+ notification.accept(listener);
+ } 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 07cb9d2..b3a27d0 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,136 @@ 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();
+ 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) {
+ throw new FatalError("DevCycle client cannot be initialized: " + fatalError.getMessage());
+ }
- do {
- if (deadline <= System.currentTimeMillis() - start) {
- throw new GeneralError("DevCycle client not initialized within 2 seconds");
+ if (!devcycleClient.isInitialized()) {
+ initializeFailed = true;
+ throw new GeneralError("DevCycle client not initialized within " + initTimeoutMS + "ms");
}
- Thread.sleep(5);
- } while (!devcycleClient.isInitialized());
+ }
}
@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 382259e..9c2a871 100644
--- a/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java
+++ b/src/test/java/com/devcycle/sdk/server/helpers/LocalConfigServer.java
@@ -12,7 +12,10 @@
public class LocalConfigServer {
private final HttpServer server;
- private String configData = "";
+ // 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;
@@ -28,11 +31,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 +54,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 0000000..5ea9c7e
--- /dev/null
+++ b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderEventsTest.java
@@ -0,0 +1,187 @@
+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 {
+ // 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;
+
+ 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 51de0a3..8251757 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")));
+ }
}