diff --git a/README.md b/README.md index 7bc3d9a..c8edef1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Java](https:/ | ✅ | Logging | The provider logs through the logging configuration of the `LDConfig` it is given. | | ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. The provider keeps no per-domain state, so it does not declare itself `isDomainScoped` and one instance may also be registered for several domains. | | ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. | -| ⚠️ | Initialization | `initialize` reports whether the LaunchDarkly client became ready, and a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. It has no timeout of its own and waits until the data source becomes valid or permanently fails: [#58](https://github.com/launchdarkly/openfeature-java-server/issues/58). | +| ✅ | Initialization | `initialize` reports whether the LaunchDarkly client became ready within the start wait time, and a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. The wait is bounded by the start wait time given to the provider constructor (or, by default, the SDK's five second default); `Provider.INFINITE_START_WAIT` waits until the data source becomes valid or the provider is shut down. | | ✅ | Shutdown | `shutdown` closes the LaunchDarkly client. A closed client cannot be restarted, so a new provider instance is required afterward. | | ✅ | Transaction Context Propagation | Provided by the OpenFeature SDK, which merges the transaction context into the evaluation context before the provider is called; no provider support is required. | | ✅ | Extending | This provider is itself an extension of the OpenFeature SDK. The underlying LaunchDarkly client is available through `getLdClient()` for functionality with no OpenFeature equivalent. | @@ -107,10 +107,22 @@ There are several other attributes which have special functionality within a sin ### Initialization and Shutdown -The LaunchDarkly supports Initialization and Shutdown using the OpenFeature API. The provider begins initialization as soon as it is constructed, and the underlying LaunchDarkly SDK will block execution based on the configured start wait time. If you wish to defer the blocking behavior, then you can use the `startWait` function when building the `LDConfig`. +The provider supports Initialization and Shutdown using the OpenFeature API. The provider begins initialization as soon as it is constructed, and by default the underlying LaunchDarkly SDK blocks during construction for up to its default start wait time of five seconds. Pass a start wait time to the provider constructor to change this. -OpenFeature will report when the provider is ready, and additionally the `setProviderAndWait` function of the OpenFeature -API can be used to wait until the provider is ready, or it has encountered a permanent error. +The start wait time bounds the whole of initialization. A positive `Duration` blocks during construction for up to that long. `Duration.ZERO` waits nowhere, so initialization fails unless the client is already ready, and the application learns when the provider becomes usable from provider events. `Provider.INFINITE_START_WAIT` does not block during construction and waits during initialization until the data source becomes valid or the provider is shut down. + +```java +var provider = new Provider(sdkKey, config, Provider.INFINITE_START_WAIT); +``` + +`Provider.INFINITE_START_WAIT` must be passed as the constructor argument. It cannot be configured through `LDConfig.Builder.startWait`, which accepts only durations the SDK can represent in milliseconds. + +OpenFeature will report when the provider is ready, and additionally the `setProviderAndWait` function of the OpenFeature API can be used to wait for the outcome of initialization. + +A retry-conformant LaunchDarkly SDK retries a failing data source indefinitely rather than failing permanently, so pairing `Provider.INFINITE_START_WAIT` with `setProviderAndWait` blocks until the data source becomes valid, however long that takes. `setProviderAndWait` holds an internal OpenFeature API lock for the duration of that wait, so `OpenFeatureAPI.getInstance().shutdown()` **cannot** end it — that call would block on the same lock. To cancel an indefinite wait, either: + +- call `shutdown()` on the provider instance directly, from another thread (it does not take the OpenFeature API lock); or +- register the provider with the non-blocking `setProvider` instead of `setProviderAndWait`, and observe readiness through provider events — then `OpenFeatureAPI.getInstance().shutdown()` works normally. It the provider has been shutdown, because the OpenFeature API has been shutdown, or because the provider was no longer in use by the OpenFeature API, then the underlying LaunchDarkly SDK will be closed. This is an important consideration if you are using the `getLdClient` method of the provider to access the underlying SDK instance. diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java index 987bda5..b64bd15 100644 --- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java +++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java @@ -11,9 +11,10 @@ import dev.openfeature.sdk.*; import java.io.IOException; +import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; /** * An OpenFeature {@link FeatureProvider} which enables the use of the LaunchDarkly Server-Side SDK for Java @@ -53,6 +54,30 @@ public String getName() { private boolean initializing = false; + private final boolean waitIndefinitely; + + // Completed when initialization reaches a terminal outcome: true when the data source becomes valid, false + // when it reaches a permanent error or the provider is shut down. An indefinite initialization blocks on this. + private final CompletableFuture initialized = new CompletableFuture<>(); + + // Set when the provider is shut down, so a data source OFF caused by closing the client is recognized as an + // expected teardown rather than reported as a provider error. + private volatile boolean closed = false; + + // The largest start wait time that the SDK can represent in milliseconds. Anything longer is treated as an + // indefinite wait rather than being passed to the SDK. + private static final Duration MAX_FINITE_START_WAIT = Duration.ofMillis(Long.MAX_VALUE); + + /** + * A start wait time which waits indefinitely, for use with + * {@link Provider#Provider(String, LDConfig, Duration)}. + *

+ * Java has no built-in infinite {@link Duration}, so this is the largest value {@link Duration} can hold. Any + * start wait time too large to represent in milliseconds is treated the same way, so this constant and any + * other "forever" duration both request an indefinite wait. + */ + public static final Duration INFINITE_START_WAIT = ChronoUnit.FOREVER.getDuration(); + /** * Create a provider with the specified SDK and default configuration. *

@@ -71,14 +96,79 @@ public Provider(String sdkKey) { * @param config a client configuration object */ public Provider(String sdkKey, LDConfig config) { - this(new LDClient(sdkKey, LDConfig.Builder.fromConfig(config) + this(makeClient(sdkKey, LDConfig.Builder.fromConfig(config)), false); + } + + /** + * Create a provider with the specified SDK key, configuration and start wait time. + *

+ * The start wait time replaces the one configured with {@link LDConfig.Builder#startWait(Duration)} and bounds + * the whole of initialization: the LaunchDarkly client waits for up to that long while it is constructed, and + * {@link Provider#initialize(EvaluationContext)} then reports whether it became ready. {@link Duration#ZERO} + * waits nowhere, so initialization fails unless the client is already ready and the application learns when it + * becomes usable from provider events. {@link Provider#INFINITE_START_WAIT} waits indefinitely: nothing is + * waited for during construction, and initialization does not complete until the data source becomes valid or + * the provider is shut down. + *

+ * {@link Provider#INFINITE_START_WAIT} must be passed here; it cannot be set through + * {@link LDConfig.Builder#startWait(Duration)}, which accepts only durations the SDK can represent in + * milliseconds. A negative start wait time behaves the same as {@link Duration#ZERO}, and a null start wait + * time uses the SDK's default start wait. + *

+ * An indefinite wait registered with {@code setProviderAndWait} blocks until the data source becomes valid, + * and holds an OpenFeature API lock for the duration, so {@code OpenFeatureAPI.shutdown()} cannot end it. To + * cancel such a wait, call {@link #shutdown()} on the provider directly from another thread, or register with + * the non-blocking {@code setProvider} instead. + * + * @param sdkKey the SDK key for your LaunchDarkly environment + * @param config a client configuration object + * @param startWait how long to wait for the client to become ready, or {@link Provider#INFINITE_START_WAIT} to + * wait indefinitely; null uses the SDK's default start wait + */ + public Provider(String sdkKey, LDConfig config, Duration startWait) { + this(sdkKey, config, startWait, isIndefinite(startWait)); + } + + private Provider(String sdkKey, LDConfig config, Duration startWait, boolean waitIndefinitely) { + this(makeClient(sdkKey, LDConfig.Builder.fromConfig(config) + .startWait(waitIndefinitely ? Duration.ZERO : startWait)), waitIndefinitely); + } + + /** + * Report whether a start wait time requests an indefinite wait. + *

+ * A duration too large to represent in milliseconds, including {@link #INFINITE_START_WAIT}, is treated as + * indefinite. This both honors a caller who spelled "forever" with some other large duration and avoids the + * overflow the SDK would throw when it converts such a value to milliseconds. + *

+ * A null start wait time is not indefinite: it falls through to the SDK, which applies its default start wait. + */ + // Package-private for direct unit testing of the classification boundary. + static boolean isIndefinite(Duration startWait) { + return startWait != null && isLongerThan(startWait, MAX_FINITE_START_WAIT); + } + + /** + * Whether the first duration is strictly longer than the second. + */ + private static boolean isLongerThan(Duration duration, Duration other) { + return duration.compareTo(other) > 0; + } + + private static LDClient makeClient(String sdkKey, LDConfig.Builder builder) { + return new LDClient(sdkKey, builder .wrapper(Components.wrapperInfo() .wrapperName("open-feature-java-server") - .wrapperVersion(Version.SDK_VERSION)).build())); + .wrapperVersion(Version.SDK_VERSION)).build()); } Provider(LDClientInterface client) { + this(client, true); + } + + Provider(LDClientInterface client, boolean waitIndefinitely) { this.client = client; + this.waitIndefinitely = waitIndefinitely; logger = client.getLogger(); evaluationContextConverter = new EvaluationContextConverter(logger); evaluationDetailConverter = new EvaluationDetailConverter(logger); @@ -148,87 +238,143 @@ public ProviderState getState() { @Override public void initialize(EvaluationContext evaluationContext) throws Exception { - // If we are ready, then set the state. Don't return, because we still need to listen for future - // changes. - if (client.isInitialized()) { - setState(ProviderState.READY); - } - setInitializing(true); - var completer = new CompletableFuture(); - - client.getFlagTracker().addFlagChangeListener(detail -> { - emitProviderConfigurationChanged( - ProviderEventDetails.builder().flagsChanged(Collections.singletonList(detail.getKey())).build()); - }); - // Listen for future status changes. - client.getDataSourceStatusProvider().addStatusListener((res) -> { - handleDataSourceStatus(res, completer); - }); - - if (getState() == ProviderState.READY) { - setInitializing(false); - return; - } - - boolean successfullyInitialized; + boolean successfullyInitialized = false; try { - handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer); - successfullyInitialized = completer.get(); + // The client's "initialized" latch stays set once it has received data; a client that was ever valid + // can serve cached data, so it is reported READY. + if (client.isInitialized()) { + setState(ProviderState.READY); + } + + // Register listeners before evaluating the current status, so a change in between is not missed. They + // are intentionally not removed on shutdown: closing the client transitions the data source to OFF, + // and the status listener must still be registered to observe that and complete an indefinite wait. + client.getFlagTracker().addFlagChangeListener(detail -> emitProviderConfigurationChanged( + ProviderEventDetails.builder().flagsChanged(Collections.singletonList(detail.getKey())).build())); + client.getDataSourceStatusProvider().addStatusListener(this::handleDataSourceStatus); + + if (getState() == ProviderState.READY) { + successfullyInitialized = true; + } else { + handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus()); + // Recording the failure with the state, under the lock, keeps a data source which becomes valid at + // the same moment from being reported as a failed initialization, or from losing its ready event. + synchronized (stateLock) { + if (!waitIndefinitely && !initialized.isDone()) { + completeState(ProviderState.ERROR); + initialized.complete(false); + } + } + // Blocks only on the indefinite path; otherwise the outcome was just recorded. The wait ends when + // the data source becomes valid or permanently fails, or when the provider is shut down. + successfullyInitialized = initialized.get(); + } + } catch (InterruptedException e) { + // CompletableFuture.get() clears the interrupt status when it throws; restore it so cooperative + // cancellation still works for the caller. + Thread.currentThread().interrupt(); + throw e; } finally { setInitializing(false); } - if(!successfullyInitialized) { - throw new RuntimeException("Failed to initialize LaunchDarkly client."); + if (!successfullyInitialized) { + // The state is already recorded, and the OpenFeature SDK emits its own error event when initialization + // fails, so no event of our own is emitted here. The cause is attached so the SDK's event carries it. + throw new RuntimeException(initializationFailureMessage()); + } + } + + private String initializationFailureMessage() { + if (closed) { + return "The provider was shut down before the LaunchDarkly client initialized."; } + DataSourceStatusProvider.ErrorInfo lastError = client.getDataSourceStatusProvider().getStatus().getLastError(); + return lastError != null + ? "Failed to initialize LaunchDarkly client: " + lastError + : "Failed to initialize LaunchDarkly client."; } - private void handleDataSourceStatus(DataSourceStatusProvider.Status res, CompletableFuture completer) { + private void handleDataSourceStatus(DataSourceStatusProvider.Status res) { switch (res.getState()) { // We will not re-enter INITIALIZING, but it is here to make the switch exhaustive. - case INITIALIZING: { - } - break; + case INITIALIZING: + break; case INTERRUPTED: { - setState(ProviderState.STALE); - - var message = res.getLastError() != null ? res.getLastError().getMessage() : "encountered an unknown error"; - emitProviderStale(ProviderEventDetails.builder().message(message).build()); + // Use toString() rather than getMessage(): an HTTP error carries its detail in toString() and has + // a null message, matching the OFF branch below. + var message = res.getLastError() != null + ? res.getLastError().toString() + : "encountered an unknown error"; + if (updateState(ProviderState.STALE)) { + emitProviderStale(ProviderEventDetails.builder().message(message).build()); + } } break; case VALID: { - boolean becameReady = false; - boolean emit = false; + boolean emit; synchronized (stateLock) { - // If we are ready, then we don't want to emit it again. Other conditions we may be updating the - // reason we are stale or interrupted, so we want to emit an event each time. - if (state != ProviderState.READY) { - becameReady = true; - // The OpenFeature SDK emits its own ready event when initialization succeeds. - emit = !initializing; - state = ProviderState.READY; - } + emit = completeState(ProviderState.READY); + initialized.complete(true); } - - if (becameReady) { - completer.complete(true); - if (emit) { - emitProviderReady(ProviderEventDetails.builder().build()); - } + if (emit) { + emitProviderReady(ProviderEventDetails.builder().build()); } } break; case OFF: { - // Currently there is not a shutdown state. - // Our client/provider cannot be restarted, so we just go to error. - setState(ProviderState.ERROR); - completer.complete(false); + // There is no shutdown state; the client/provider cannot be restarted, so this becomes an error. + boolean emit; + synchronized (stateLock) { + emit = completeState(ProviderState.ERROR); + initialized.complete(false); + } var message = res.getLastError() != null ? res.getLastError().toString() : "the provider has encountered a permanent error or has been shutdown"; - emitProviderError(ProviderEventDetails.builder().message(message).errorCode(ErrorCode.GENERAL).build()); + // Closing the client reports OFF; that is an expected teardown, not a provider error, so suppress it. + if (emit && !closed) { + emitProviderError( + ProviderEventDetails.builder().message(message).errorCode(ErrorCode.GENERAL).build()); + } + } + break; + default: + break; + } + } + + /** + * Record the provider state and report whether the change should be emitted. + *

+ * A state which does not change is not emitted; several data source statuses can map to the same provider + * state, and a repeated state is not a change an application can act on. The state which completes + * initialization is also not emitted here, because the OpenFeature SDK emits its own event for it. + */ + private boolean updateState(ProviderState newState) { + synchronized (stateLock) { + if (state == newState) { + return false; } + state = newState; + return !initializing; + } + } + + /** + * Record the provider state which completes initialization and report whether the change should be emitted. + *

+ * The OpenFeature SDK emits its own event for the transition which completes initialization, so that one is not + * emitted here, but any state change after it is. + */ + private boolean completeState(ProviderState newState) { + synchronized (stateLock) { + var changed = state != newState; + var wasInitializing = initializing; + state = newState; + initializing = false; + return changed && !wasInitializing; } } @@ -246,10 +392,14 @@ private void setState(ProviderState state) { @Override public void shutdown() { + closed = true; try { client.close(); } catch (IOException e) { throw new RuntimeException(e); + } finally { + // Terminate the event emitter executor that EventProvider starts; otherwise it is leaked. + super.shutdown(); } } diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java index a0e8dd5..684c87b 100644 --- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java +++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java @@ -32,7 +32,9 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class DelayedDataSource implements DataSource { @@ -42,6 +44,7 @@ class DelayedDataSource implements DataSource { private boolean useHttpError; private boolean initialized = false; private Object lock = new Object(); + private Timer timer; DataSourceUpdateSink sink; DelayedDataSource(Duration delay, boolean error, DataSourceUpdateSink sink) { @@ -63,7 +66,7 @@ class DelayedDataSource implements DataSource { public Future start() { var future = new CompletableFuture(); - var timer = new Timer(); + timer = new Timer(true); timer.schedule(new TimerTask() { @Override public void run() { @@ -102,6 +105,9 @@ public boolean isInitialized() { } public void close() throws IOException { + if (timer != null) { + timer.cancel(); + } } } @@ -239,7 +245,7 @@ public void itCanHandleClientThatIsNotInitializedImmediately() throws Exception .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), false)) .events(Components.noEvents()) .build(); - var provider = new Provider("fake-key", config); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); assertEquals(ProviderState.NOT_READY, provider.getState()); var readyCount = new AtomicInteger(); @@ -266,7 +272,7 @@ public void itCanHandleClientThatIsNotInitializedImmediatelyAndErrors() throws E .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), true)) .events(Components.noEvents()) .build(); - var provider = new Provider("fake-key", config); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); assertEquals(ProviderState.NOT_READY, provider.getState()); CompletableFuture gotErrorEvent = new CompletableFuture<>(); @@ -297,7 +303,7 @@ public void itEmitsReadyWhenTheDataSourceRecoversFromAFailedInitialization() thr .dataSource(dataSourceFactory) .events(Components.noEvents()) .build(); - var provider = new Provider("fake-key", config); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); var sink = dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS); var readyCount = new AtomicInteger(); @@ -344,7 +350,7 @@ public void itIncludesTheDataSourceErrorInErrorEvents() throws Exception { .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), false, true)) .events(Components.noEvents()) .build(); - var provider = new Provider("fake-key", config); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); CompletableFuture errorMessage = new CompletableFuture<>(); OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> { @@ -365,7 +371,7 @@ public void itIncludesAnErrorCodeInErrorEvents() throws Exception { .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), false, true)) .events(Components.noEvents()) .build(); - var provider = new Provider("fake-key", config); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); CompletableFuture errorCode = new CompletableFuture<>(); OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> { @@ -384,7 +390,7 @@ public void itIncludesHttpDataSourceErrorInErrorEvents() throws Exception { .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), false, true, true)) .events(Components.noEvents()) .build(); - var provider = new Provider("fake-key", config); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); CompletableFuture errorMessage = new CompletableFuture<>(); OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> { @@ -398,4 +404,236 @@ public void itIncludesHttpDataSourceErrorInErrorEvents() throws Exception { assertTrue(!message.isEmpty()); assertTrue(message.contains("401")); } + + @Test + public void itDoesNotWaitAgainWithAStartWaitTime() { + var config = new LDConfig.Builder() + .dataSource(new DelayedDataSourceFactory(Duration.ofSeconds(30), false)) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Duration.ofMillis(50)); + + var started = System.currentTimeMillis(); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(provider)); + + assertTrue(System.currentTimeMillis() - started < 1000); + assertEquals(ProviderState.ERROR, provider.getState()); + } + + @Test + public void itDoesNotWaitWithAStartWaitTimeOfZero() { + var config = new LDConfig.Builder() + .dataSource(new DelayedDataSourceFactory(Duration.ofSeconds(30), false)) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Duration.ZERO); + + var started = System.currentTimeMillis(); + assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(provider)); + + assertTrue(System.currentTimeMillis() - started < 1000); + } + + @Test + public void itBecomesReadyWithinAPositiveStartWait() throws Exception { + var config = new LDConfig.Builder() + .dataSource(new DelayedDataSourceFactory(Duration.ofMillis(50), false)) + .events(Components.noEvents()) + .build(); + // A positive start wait longer than the data source takes to become valid: initialization succeeds. + var provider = new Provider("fake-key", config, Duration.ofSeconds(5)); + + var readyCount = new AtomicInteger(); + CompletableFuture gotReadyEvent = new CompletableFuture<>(); + OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_READY, (detail) -> { + readyCount.getAndIncrement(); + gotReadyEvent.complete(true); + }); + + var started = System.currentTimeMillis(); + OpenFeatureAPI.getInstance().setProviderAndWait(provider); + + assertEquals(ProviderState.READY, provider.getState()); + assertTrue(System.currentTimeMillis() - started < 5000); + assertTrue(gotReadyEvent.get(1000, TimeUnit.MILLISECONDS)); + Thread.sleep(100); + assertEquals(1, readyCount.get()); + } + + @Test + public void itEmitsStaleThenReadyOnInterruptionAndRecovery() throws Exception { + var dataSourceFactory = new ControllableDataSourceFactory(); + var config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataSource(dataSourceFactory) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); + var sink = dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS); + + var readyCount = new AtomicInteger(); + var staleCount = new AtomicInteger(); + CompletableFuture staleMessage = new CompletableFuture<>(); + CompletableFuture recoveredReadyEvent = new CompletableFuture<>(); + + OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_READY, (detail) -> { + if (readyCount.incrementAndGet() == 2) { + recoveredReadyEvent.complete(true); + } + }); + OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_STALE, (detail) -> { + staleCount.incrementAndGet(); + staleMessage.complete(detail.getMessage()); + }); + + // Complete initialization. The OpenFeature SDK emits the first ready event itself for the init outcome. + sink.updateStatus(DataSourceStatusProvider.State.VALID, null); + OpenFeatureAPI.getInstance().setProviderAndWait(provider); + assertEquals(ProviderState.READY, provider.getState()); + + // An interruption is reported as stale, carrying the HTTP error detail. + sink.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, + DataSourceStatusProvider.ErrorInfo.fromHttpError(503)); + var message = staleMessage.get(1000, TimeUnit.MILLISECONDS); + assertTrue(message.contains("503")); + + // Recovery is reported as ready again. + sink.updateStatus(DataSourceStatusProvider.State.VALID, null); + assertTrue(recoveredReadyEvent.get(1000, TimeUnit.MILLISECONDS)); + + Thread.sleep(100); + assertEquals(2, readyCount.get()); + assertEquals(1, staleCount.get()); + } + + @Test + public void itEmitsOnlyOneStaleEventForRepeatedInterruptions() throws Exception { + var dataSourceFactory = new ControllableDataSourceFactory(); + var config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataSource(dataSourceFactory) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); + var sink = dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS); + + var staleCount = new AtomicInteger(); + CompletableFuture firstStaleEvent = new CompletableFuture<>(); + OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_STALE, (detail) -> { + staleCount.incrementAndGet(); + firstStaleEvent.complete(true); + }); + + sink.updateStatus(DataSourceStatusProvider.State.VALID, null); + OpenFeatureAPI.getInstance().setProviderAndWait(provider); + + // Repeated interruptions, each with a different error, map to the same STALE provider state and emit once. + sink.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, + DataSourceStatusProvider.ErrorInfo.fromHttpError(503)); + assertTrue(firstStaleEvent.get(1000, TimeUnit.MILLISECONDS)); + + sink.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, + DataSourceStatusProvider.ErrorInfo.fromHttpError(401)); + + Thread.sleep(200); + assertEquals(1, staleCount.get()); + } + + @Test + public void aStartWaitTimeTooLargeToRepresentWaitsIndefinitely() { + var config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataSource(new DelayedDataSourceFactory(Duration.ofSeconds(30), false)) + .events(Components.noEvents()) + .build(); + // Not Provider.INFINITE_START_WAIT, but another "forever" value. The range rule treats it as indefinite + // rather than passing it to the SDK, where converting it to milliseconds would overflow. + var provider = assertDoesNotThrow(() -> + new Provider("fake-key", config, Duration.ofSeconds(Long.MAX_VALUE))); + + // Indefinite waiting does not block construction, so the provider is constructed promptly and not ready. + assertEquals(ProviderState.NOT_READY, provider.getState()); + provider.shutdown(); + } + + @Test + public void aNullStartWaitTimeUsesTheDefaultAndDoesNotThrow() { + // A null start wait time is accepted rather than throwing; it falls through to the SDK's default. + assertDoesNotThrow(() -> { + var provider = new Provider("fake-key", new LDConfig.Builder().offline(true).build(), null); + provider.initialize(new ImmutableContext("context-key")); + assertEquals(ProviderState.READY, provider.getState()); + }); + } + + @Test + public void anOffDataSourceStatusCancelsAnIndefiniteInitialization() throws Exception { + var dataSourceFactory = new ControllableDataSourceFactory(); + var config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataSource(dataSourceFactory) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT); + var sink = dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS); + + // An indefinite initialization parks until the data source resolves. Closing the client transitions the + // data source to OFF; drive that status directly to show the provider's status listener ends the wait + // (cancellation runs through the status path rather than through shutdown() itself). + CompletableFuture initOutcome = new CompletableFuture<>(); + var initThread = new Thread(() -> { + try { + provider.initialize(new ImmutableContext("context-key")); + initOutcome.complete(null); + } catch (Throwable t) { + initOutcome.complete(t); + } + }); + initThread.start(); + + // Let initialization park on the wait, then deliver OFF. + Thread.sleep(200); + sink.updateStatus(DataSourceStatusProvider.State.OFF, new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, 500, "shutting down", + LocalDateTime.now().toInstant(ZoneOffset.UTC))); + + var outcome = initOutcome.get(2000, TimeUnit.MILLISECONDS); + assertNotNull(outcome); + assertTrue(outcome instanceof RuntimeException); + } + + @Test + public void aFailedBoundedInitializationSurfacesTheDataSourceError() throws Exception { + var dataSourceFactory = new ControllableDataSourceFactory(); + var config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataSource(dataSourceFactory) + .events(Components.noEvents()) + .build(); + var provider = new Provider("fake-key", config, Duration.ZERO); + var sink = dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS); + + // A bounded initialization fails; the thrown error must carry the data source's last error detail. + sink.updateStatus(DataSourceStatusProvider.State.OFF, + DataSourceStatusProvider.ErrorInfo.fromHttpError(401)); + + var error = assertThrows(GeneralError.class, + () -> OpenFeatureAPI.getInstance().setProviderAndWait(provider)); + assertTrue(error.getMessage().contains("401")); + } + + @Test + public void startWaitClassificationIsBoundaryCorrect() { + // Indefinite: the sentinel, and any duration too large to represent in milliseconds. + assertTrue(Provider.isIndefinite(Provider.INFINITE_START_WAIT)); + assertTrue(Provider.isIndefinite(Duration.ofSeconds(Long.MAX_VALUE))); + assertTrue(Provider.isIndefinite(Duration.ofMillis(Long.MAX_VALUE).plusMillis(1))); + // Bounded: exactly the largest representable millis, and smaller values. + assertFalse(Provider.isIndefinite(Duration.ofMillis(Long.MAX_VALUE))); + assertFalse(Provider.isIndefinite(Duration.ofSeconds(5))); + assertFalse(Provider.isIndefinite(Duration.ZERO)); + assertFalse(Provider.isIndefinite(Duration.ofSeconds(-1))); + // Null is not indefinite; it falls through to the SDK default. + assertFalse(Provider.isIndefinite(null)); + } }