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"))); + } }