From 3f84681070d0505b2debb55ab0e6eb4b7ea98a3d Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 14:54:23 +0000
Subject: [PATCH 1/8] feat: Add a start wait time option for initialization
Fixes #58
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
---
.../openfeature/serverprovider/Provider.java | 38 +++++++++++++++++-
.../serverprovider/LifeCycleTest.java | 40 ++++++++++++++++---
2 files changed, 71 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
index b83e3d9..794811b 100644
--- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
+++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
@@ -11,6 +11,7 @@
import dev.openfeature.sdk.*;
import java.io.IOException;
+import java.time.Duration;
import java.util.Collections;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
@@ -53,6 +54,8 @@ public String getName() {
private boolean initializing = false;
+ private final boolean waitIndefinitely;
+
/**
* Create a provider with the specified SDK and default configuration.
*
@@ -71,14 +74,42 @@ 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. A null start wait time waits indefinitely: nothing is waited for during
+ * construction, and initialization does not complete until the data source becomes valid or fails permanently.
+ *
+ * @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 null to wait indefinitely
+ */
+ public Provider(String sdkKey, LDConfig config, Duration startWait) {
+ this(makeClient(sdkKey, LDConfig.Builder.fromConfig(config)
+ .startWait(startWait == null ? Duration.ZERO : startWait)), startWait == null);
+ }
+
+ 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);
@@ -174,6 +205,9 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
boolean successfullyInitialized;
try {
handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer);
+ if (!waitIndefinitely) {
+ completer.complete(false);
+ }
successfullyInitialized = completer.get();
} finally {
setInitializing(false);
diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
index 929b204..8f9141d 100644
--- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
+++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
@@ -32,6 +32,7 @@
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 {
@@ -238,7 +239,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, null);
assertEquals(ProviderState.NOT_READY, provider.getState());
var readyCount = new AtomicInteger();
@@ -265,7 +266,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, null);
assertEquals(ProviderState.NOT_READY, provider.getState());
CompletableFuture gotErrorEvent = new CompletableFuture<>();
@@ -296,7 +297,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, null);
var sink = dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS);
var readyCount = new AtomicInteger();
@@ -343,7 +344,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, null);
CompletableFuture errorMessage = new CompletableFuture<>();
OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> {
@@ -364,7 +365,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, null);
CompletableFuture errorMessage = new CompletableFuture<>();
OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> {
@@ -378,4 +379,33 @@ 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.NOT_READY, 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);
+ }
}
From aecf81e47b8a47e3cc4f87e7cd65c72278f023bc Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 14:58:59 +0000
Subject: [PATCH 2/8] docs: Describe the start wait time option
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
---
README.md | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 1a6e57f..31a4a47 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. |
| ✅ | 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 `LDConfig.Builder.startWait` or by the start wait time given to the provider constructor, which may be null to wait indefinitely. |
| ✅ | 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,7 +107,13 @@ 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 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`, or pass a start wait time to the provider constructor.
+
+The start wait time bounds the whole of initialization. `Duration.ZERO` waits nowhere, so initialization fails and the application learns when the provider becomes usable from provider events. A null start wait time waits indefinitely, without blocking during construction, until the data source becomes valid or fails permanently.
+
+```java
+var provider = new Provider(sdkKey, config, null);
+```
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.
From 8ca9bb5125456f9df9ea17a0253be0687e301be8 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Tue, 29 Sep 2026 22:53:26 +0000
Subject: [PATCH 3/8] fix: Do not emit duplicate or unchanged provider status
events
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
---
.../openfeature/serverprovider/Provider.java | 53 +++++++++++--------
.../serverprovider/LifeCycleTest.java | 2 +-
2 files changed, 31 insertions(+), 24 deletions(-)
diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
index 794811b..4270dce 100644
--- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
+++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
@@ -214,6 +214,9 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
}
if(!successfullyInitialized) {
+ // The OpenFeature SDK emits its own error event when initialization fails, so the state is recorded
+ // without emitting an event of our own.
+ setState(ProviderState.ERROR);
throw new RuntimeException("Failed to initialize LaunchDarkly client.");
}
}
@@ -225,44 +228,48 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet
}
break;
case INTERRUPTED: {
- setState(ProviderState.STALE);
-
var message = res.getLastError() != null ? res.getLastError().getMessage() : "encountered an unknown error";
- emitProviderStale(ProviderEventDetails.builder().message(message).build());
+ if (updateState(ProviderState.STALE)) {
+ emitProviderStale(ProviderEventDetails.builder().message(message).build());
+ }
}
break;
case VALID: {
- boolean becameReady = false;
- boolean emit = false;
- 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;
- }
- }
-
- if (becameReady) {
- completer.complete(true);
- if (emit) {
- emitProviderReady(ProviderEventDetails.builder().build());
- }
+ boolean emit = updateState(ProviderState.READY);
+ completer.complete(true);
+ 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);
+ boolean emit = updateState(ProviderState.ERROR);
completer.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).build());
+ if (emit) {
+ emitProviderError(ProviderEventDetails.builder().message(message).build());
+ }
+ }
+ }
+ }
+
+ /**
+ * Record the provider state and report whether the change should be emitted.
+ *
+ * A state which does not change is not emitted, and neither is the state which completes initialization, because
+ * the OpenFeature SDK emits its own event for that one.
+ */
+ private boolean updateState(ProviderState newState) {
+ synchronized (stateLock) {
+ if (state == newState) {
+ return false;
}
+ state = newState;
+ return !initializing;
}
}
diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
index 8f9141d..11e4836 100644
--- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
+++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
@@ -392,7 +392,7 @@ public void itDoesNotWaitAgainWithAStartWaitTime() {
assertThrows(GeneralError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(provider));
assertTrue(System.currentTimeMillis() - started < 1000);
- assertEquals(ProviderState.NOT_READY, provider.getState());
+ assertEquals(ProviderState.ERROR, provider.getState());
}
@Test
From a9516430e793c1d200ebe3711418817dad3131b1 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 28 Oct 2026 18:16:01 +0000
Subject: [PATCH 4/8] fix: Use Provider.INFINITE_START_WAIT rather than null
for indefinite waiting
Co-Authored-By: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
---
README.md | 6 +++---
.../openfeature/serverprovider/Provider.java | 18 ++++++++++++++----
.../serverprovider/LifeCycleTest.java | 10 +++++-----
3 files changed, 22 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index 31a4a47..89145f4 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. |
| ✅ | 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 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 `LDConfig.Builder.startWait` or by the start wait time given to the provider constructor, which may be null to wait indefinitely. |
+| ✅ | 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 `LDConfig.Builder.startWait` or by the start wait time given to the provider constructor, which may be `Provider.INFINITE_START_WAIT` to wait indefinitely. |
| ✅ | 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. |
@@ -109,10 +109,10 @@ There are several other attributes which have special functionality within a sin
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`, or pass a start wait time to the provider constructor.
-The start wait time bounds the whole of initialization. `Duration.ZERO` waits nowhere, so initialization fails and the application learns when the provider becomes usable from provider events. A null start wait time waits indefinitely, without blocking during construction, until the data source becomes valid or fails permanently.
+The start wait time bounds the whole of initialization. `Duration.ZERO` waits nowhere, so initialization fails and the application learns when the provider becomes usable from provider events. `Provider.INFINITE_START_WAIT` waits indefinitely, without blocking during construction, until the data source becomes valid or fails permanently.
```java
-var provider = new Provider(sdkKey, config, null);
+var provider = new Provider(sdkKey, config, Provider.INFINITE_START_WAIT);
```
OpenFeature will report when the provider is ready, and additionally the `setProviderAndWait` function of the OpenFeature
diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
index 4270dce..77ceb84 100644
--- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
+++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
@@ -12,6 +12,7 @@
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;
@@ -56,6 +57,12 @@ public String getName() {
private final boolean waitIndefinitely;
+ /**
+ * A start wait time which waits indefinitely, for use with
+ * {@link Provider#Provider(String, LDConfig, Duration)}.
+ */
+ public static final Duration INFINITE_START_WAIT = ChronoUnit.FOREVER.getDuration();
+
/**
* Create a provider with the specified SDK and default configuration.
*
@@ -84,16 +91,19 @@ public Provider(String sdkKey, LDConfig config) {
* 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. A null start wait time waits indefinitely: nothing is waited for during
- * construction, and initialization does not complete until the data source becomes valid or fails permanently.
+ * 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 fails
+ * permanently.
*
* @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 null to wait indefinitely
+ * @param startWait how long to wait for the client to become ready, or {@link Provider#INFINITE_START_WAIT} to
+ * wait indefinitely
*/
public Provider(String sdkKey, LDConfig config, Duration startWait) {
this(makeClient(sdkKey, LDConfig.Builder.fromConfig(config)
- .startWait(startWait == null ? Duration.ZERO : startWait)), startWait == null);
+ .startWait(INFINITE_START_WAIT.equals(startWait) ? Duration.ZERO : startWait)),
+ INFINITE_START_WAIT.equals(startWait));
}
private static LDClient makeClient(String sdkKey, LDConfig.Builder builder) {
diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
index 11e4836..c97e724 100644
--- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
+++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
@@ -239,7 +239,7 @@ public void itCanHandleClientThatIsNotInitializedImmediately() throws Exception
.dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), false))
.events(Components.noEvents())
.build();
- var provider = new Provider("fake-key", config, null);
+ var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT);
assertEquals(ProviderState.NOT_READY, provider.getState());
var readyCount = new AtomicInteger();
@@ -266,7 +266,7 @@ public void itCanHandleClientThatIsNotInitializedImmediatelyAndErrors() throws E
.dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), true))
.events(Components.noEvents())
.build();
- var provider = new Provider("fake-key", config, null);
+ var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT);
assertEquals(ProviderState.NOT_READY, provider.getState());
CompletableFuture gotErrorEvent = new CompletableFuture<>();
@@ -297,7 +297,7 @@ public void itEmitsReadyWhenTheDataSourceRecoversFromAFailedInitialization() thr
.dataSource(dataSourceFactory)
.events(Components.noEvents())
.build();
- var provider = new Provider("fake-key", config, null);
+ 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 +344,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, null);
+ var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT);
CompletableFuture errorMessage = new CompletableFuture<>();
OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> {
@@ -365,7 +365,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, null);
+ var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT);
CompletableFuture errorMessage = new CompletableFuture<>();
OpenFeatureAPI.getInstance().on(ProviderEvent.PROVIDER_ERROR, (detail) -> {
From 7ac25308e55a53b6e8e9288fce70d60de23ff51c Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 28 Oct 2026 18:22:54 +0000
Subject: [PATCH 5/8] fix: Emit status changes which arrive right after
initialization completes
Co-Authored-By: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
---
.../openfeature/serverprovider/Provider.java | 27 ++++++++++++++++---
.../serverprovider/LifeCycleTest.java | 2 +-
2 files changed, 24 insertions(+), 5 deletions(-)
diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
index fd03a34..76d1358 100644
--- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
+++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
@@ -101,9 +101,12 @@ public Provider(String sdkKey, LDConfig config) {
* wait indefinitely
*/
public Provider(String sdkKey, LDConfig config, Duration startWait) {
+ this(sdkKey, config, startWait, INFINITE_START_WAIT.equals(startWait));
+ }
+
+ private Provider(String sdkKey, LDConfig config, Duration startWait, boolean waitIndefinitely) {
this(makeClient(sdkKey, LDConfig.Builder.fromConfig(config)
- .startWait(INFINITE_START_WAIT.equals(startWait) ? Duration.ZERO : startWait)),
- INFINITE_START_WAIT.equals(startWait));
+ .startWait(waitIndefinitely ? Duration.ZERO : startWait)), waitIndefinitely);
}
private static LDClient makeClient(String sdkKey, LDConfig.Builder builder) {
@@ -245,7 +248,7 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet
}
break;
case VALID: {
- boolean emit = updateState(ProviderState.READY);
+ boolean emit = completeState(ProviderState.READY);
completer.complete(true);
if (emit) {
emitProviderReady(ProviderEventDetails.builder().build());
@@ -255,7 +258,7 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet
case OFF: {
// Currently there is not a shutdown state.
// Our client/provider cannot be restarted, so we just go to error.
- boolean emit = updateState(ProviderState.ERROR);
+ boolean emit = completeState(ProviderState.ERROR);
completer.complete(false);
var message = res.getLastError() != null
? res.getLastError().toString()
@@ -284,6 +287,22 @@ private boolean updateState(ProviderState newState) {
}
}
+ /**
+ * 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;
+ }
+ }
+
private void setInitializing(boolean initializing) {
synchronized (stateLock) {
this.initializing = initializing;
diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
index b38b8b4..17c1fd9 100644
--- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
+++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
@@ -366,7 +366,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) -> {
From 945a226aeb171e8142876f0fbbc7fab4bd418845 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 28 Oct 2026 18:37:42 +0000
Subject: [PATCH 6/8] fix: Record the initialization outcome atomically with
the provider state
Co-Authored-By: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
---
.../openfeature/serverprovider/Provider.java | 30 ++++++++++++-------
1 file changed, 20 insertions(+), 10 deletions(-)
diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
index 76d1358..4242bc7 100644
--- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
+++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
@@ -218,18 +218,22 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
boolean successfullyInitialized;
try {
handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer);
- if (!waitIndefinitely) {
- completer.complete(false);
+ // 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 && !completer.isDone()) {
+ completeState(ProviderState.ERROR);
+ completer.complete(false);
+ }
}
successfullyInitialized = completer.get();
} finally {
setInitializing(false);
}
- if(!successfullyInitialized) {
- // The OpenFeature SDK emits its own error event when initialization fails, so the state is recorded
- // without emitting an event of our own.
- setState(ProviderState.ERROR);
+ 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.
throw new RuntimeException("Failed to initialize LaunchDarkly client.");
}
}
@@ -248,8 +252,11 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet
}
break;
case VALID: {
- boolean emit = completeState(ProviderState.READY);
- completer.complete(true);
+ boolean emit;
+ synchronized (stateLock) {
+ emit = completeState(ProviderState.READY);
+ completer.complete(true);
+ }
if (emit) {
emitProviderReady(ProviderEventDetails.builder().build());
}
@@ -258,8 +265,11 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet
case OFF: {
// Currently there is not a shutdown state.
// Our client/provider cannot be restarted, so we just go to error.
- boolean emit = completeState(ProviderState.ERROR);
- completer.complete(false);
+ boolean emit;
+ synchronized (stateLock) {
+ emit = completeState(ProviderState.ERROR);
+ completer.complete(false);
+ }
var message = res.getLastError() != null
? res.getLastError().toString()
: "the provider has encountered a permanent error or has been shutdown";
From 2ef5a1a444aa8124cc652d49a458ed452ed5b0d2 Mon Sep 17 00:00:00 2001
From: Todd Anderson
Date: Fri, 30 Oct 2026 09:53:17 -0400
Subject: [PATCH 7/8] fix: Harden start wait time handling and provider
lifecycle
Reworks the start wait time behavior in response to review:
- An indefinite initialization (Provider.INFINITE_START_WAIT) can now be
cancelled: shutdown() completes the initialization future, and the
interrupt flag is restored if the waiting thread is interrupted.
Documents that a direct provider.shutdown() (from another thread) or the
non-blocking setProvider is required to end such a wait, because
OpenFeatureAPI.shutdown() would block on the API lock setProviderAndWait
holds for the duration of the wait.
- Classifies any start wait time too large to represent in milliseconds,
including INFINITE_START_WAIT, as indefinite, so it saturates instead of
overflowing when the SDK converts it. A null start wait time falls
through to the SDK's default rather than throwing.
- A failed initialization carries the data source's last error into the
thrown exception, so the OpenFeature error event reports the cause.
- Does not report READY from the client's initialized latch while the data
source is OFF.
- Keeps emitting one stale event per state change, and stale events carry
the HTTP error detail.
- shutdown() removes its listeners and terminates the event emitter
executor.
Adds lifecycle tests for these paths and makes start-wait classification
directly unit-testable.
---
README.md | 16 +-
.../openfeature/serverprovider/Provider.java | 198 ++++++++++----
.../serverprovider/LifeCycleTest.java | 241 +++++++++++++++++-
.../serverprovider/ProviderTest.java | 27 ++
4 files changed, 423 insertions(+), 59 deletions(-)
diff --git a/README.md b/README.md
index 3b4e26f..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 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 `LDConfig.Builder.startWait` or by the start wait time given to the provider constructor, which may be `Provider.INFINITE_START_WAIT` to wait indefinitely. |
+| ✅ | 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,16 +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`, or pass a start wait time to the provider constructor.
+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.
-The start wait time bounds the whole of initialization. `Duration.ZERO` waits nowhere, so initialization fails and the application learns when the provider becomes usable from provider events. `Provider.INFINITE_START_WAIT` waits indefinitely, without blocking during construction, until the data source becomes valid or fails permanently.
+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);
```
-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.
+`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 4242bc7..f570168 100644
--- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
+++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
@@ -7,6 +7,7 @@
import com.launchdarkly.sdk.server.LDClient;
import com.launchdarkly.sdk.server.LDConfig;
import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider;
+import com.launchdarkly.sdk.server.interfaces.FlagChangeListener;
import com.launchdarkly.sdk.server.interfaces.LDClientInterface;
import dev.openfeature.sdk.*;
@@ -15,7 +16,6 @@
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
@@ -57,9 +57,29 @@ public String getName() {
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;
+
+ // Registered in initialize() and removed in shutdown(). Held so they can be removed rather than leaked.
+ private volatile FlagChangeListener flagChangeListener;
+ private volatile DataSourceStatusProvider.StatusListener statusListener;
+
+ // 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();
@@ -87,21 +107,31 @@ public Provider(String sdkKey, LDConfig config) {
/**
* 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 fails
- * permanently.
+ * 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
+ * wait indefinitely; null uses the SDK's default start wait
*/
public Provider(String sdkKey, LDConfig config, Duration startWait) {
- this(sdkKey, config, startWait, INFINITE_START_WAIT.equals(startWait));
+ this(sdkKey, config, startWait, isIndefinite(startWait));
}
private Provider(String sdkKey, LDConfig config, Duration startWait, boolean waitIndefinitely) {
@@ -109,6 +139,27 @@ private Provider(String sdkKey, LDConfig config, Duration startWait, boolean wai
.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()
@@ -192,60 +243,77 @@ 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();
+ boolean successfullyInitialized = false;
+ try {
+ // The client's "initialized" latch stays set once it has received data, even while the data source is
+ // interrupted; cached data can still be served, so that is reported READY. A data source which is OFF
+ // is excluded: the client cannot recover from it, so it must not be reported as ready.
+ DataSourceStatusProvider.Status currentStatus = client.getDataSourceStatusProvider().getStatus();
+ if (client.isInitialized() && currentStatus.getState() != DataSourceStatusProvider.State.OFF) {
+ setState(ProviderState.READY);
+ }
- client.getFlagTracker().addFlagChangeListener(detail -> {
- emitProviderConfigurationChanged(
+ // Register listeners before evaluating the current status, so a change in between is not missed.
+ flagChangeListener = detail -> emitProviderConfigurationChanged(
ProviderEventDetails.builder().flagsChanged(Collections.singletonList(detail.getKey())).build());
- });
- // Listen for future status changes.
- client.getDataSourceStatusProvider().addStatusListener((res) -> {
- handleDataSourceStatus(res, completer);
- });
+ client.getFlagTracker().addFlagChangeListener(flagChangeListener);
+ statusListener = this::handleDataSourceStatus;
+ client.getDataSourceStatusProvider().addStatusListener(statusListener);
- if (getState() == ProviderState.READY) {
- setInitializing(false);
- return;
- }
-
- boolean successfullyInitialized;
- try {
- handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer);
- // 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 && !completer.isDone()) {
- completeState(ProviderState.ERROR);
- completer.complete(false);
+ 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();
}
- successfullyInitialized = completer.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) {
// 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.
- throw new RuntimeException("Failed to initialize LaunchDarkly client.");
+ // 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 void handleDataSourceStatus(DataSourceStatusProvider.Status res, CompletableFuture completer) {
+ 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) {
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: {
- var message = res.getLastError() != null ? res.getLastError().getMessage() : "encountered an unknown error";
+ // 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());
}
@@ -255,7 +323,7 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet
boolean emit;
synchronized (stateLock) {
emit = completeState(ProviderState.READY);
- completer.complete(true);
+ initialized.complete(true);
}
if (emit) {
emitProviderReady(ProviderEventDetails.builder().build());
@@ -263,29 +331,33 @@ private void handleDataSourceStatus(DataSourceStatusProvider.Status res, Complet
}
break;
case OFF: {
- // Currently there is not a shutdown state.
- // Our client/provider cannot be restarted, so we just go to error.
+ // There is no shutdown state; the client/provider cannot be restarted, so this becomes an error.
boolean emit;
synchronized (stateLock) {
emit = completeState(ProviderState.ERROR);
- completer.complete(false);
+ initialized.complete(false);
}
var message = res.getLastError() != null
? res.getLastError().toString()
: "the provider has encountered a permanent error or has been shutdown";
- if (emit) {
+ // 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, and neither is the state which completes initialization, because
- * the OpenFeature SDK emits its own event for that one.
+ * 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) {
@@ -327,10 +399,30 @@ private void setState(ProviderState state) {
@Override
public void shutdown() {
+ closed = true;
+ // Unblock an indefinite initialization that is still waiting. Under the retry-conformant SDK the data
+ // source never reports OFF for a transport failure, so shutting down is, besides the data source becoming
+ // valid, the only thing that ends the wait.
+ initialized.complete(false);
+ removeListeners();
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();
+ }
+ }
+
+ private void removeListeners() {
+ var currentStatusListener = statusListener;
+ if (currentStatusListener != null) {
+ client.getDataSourceStatusProvider().removeStatusListener(currentStatusListener);
+ }
+ var currentFlagChangeListener = flagChangeListener;
+ if (currentFlagChangeListener != null) {
+ client.getFlagTracker().removeFlagChangeListener(currentFlagChangeListener);
}
}
diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
index 17c1fd9..ddc3979 100644
--- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
+++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
@@ -32,6 +32,7 @@
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;
@@ -43,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) {
@@ -64,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() {
@@ -103,6 +105,9 @@ public boolean isInitialized() {
}
public void close() throws IOException {
+ if (timer != null) {
+ timer.cancel();
+ }
}
}
@@ -428,4 +433,238 @@ public void itDoesNotWaitWithAStartWaitTimeOfZero() {
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 shutdownEndsAnIndefiniteInitialization() 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);
+ dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS);
+
+ // The data source never becomes valid, so an indefinite initialization would otherwise wait forever.
+ 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 shut down to end it.
+ Thread.sleep(200);
+ provider.shutdown();
+
+ var outcome = initOutcome.get(2000, TimeUnit.MILLISECONDS);
+ assertNotNull(outcome);
+ assertTrue(outcome instanceof RuntimeException);
+ }
+
+ @Test
+ public void aDirectProviderShutdownCancelsAnIndefiniteSetProviderAndWait() throws Exception {
+ // The documented escape for an indefinite setProviderAndWait that never becomes ready: a direct
+ // provider.shutdown() from another thread (OpenFeatureAPI.shutdown() would deadlock on the API lock
+ // setProviderAndWait holds).
+ 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);
+ dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS);
+
+ CompletableFuture setProviderOutcome = new CompletableFuture<>();
+ var waiter = new Thread(() -> {
+ try {
+ OpenFeatureAPI.getInstance().setProviderAndWait(provider);
+ setProviderOutcome.complete(null);
+ } catch (Throwable t) {
+ setProviderOutcome.complete(t);
+ }
+ });
+ waiter.start();
+
+ // The data source never becomes valid, so setProviderAndWait is parked indefinitely.
+ Thread.sleep(300);
+ assertFalse(setProviderOutcome.isDone());
+
+ provider.shutdown();
+
+ var outcome = setProviderOutcome.get(2000, TimeUnit.MILLISECONDS);
+ assertNotNull(outcome);
+ }
+
+ @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));
+ }
}
diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/ProviderTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/ProviderTest.java
index 65e3115..c0d480c 100644
--- a/src/test/java/com/launchdarkly/openfeature/serverprovider/ProviderTest.java
+++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/ProviderTest.java
@@ -235,4 +235,31 @@ public void itCanTrackAnIntegerTrackingEventValue() {
valueConverter.toLdValue(new Value(trackingEventDetails)),
99.0);
}
+
+ @Test
+ public void shutdownRemovesTheStatusAndFlagListeners() throws Exception {
+ ldProvider.initialize(new ImmutableContext("user-key"));
+ ldProvider.shutdown();
+
+ verify(mockedLdClient.getDataSourceStatusProvider()).removeStatusListener(any());
+ verify(mockedLdClient.getFlagTracker()).removeFlagChangeListener(any());
+ }
+
+ @Test
+ public void initializeFailsWhenTheClientIsInitializedButTheDataSourceIsOff() {
+ // The client's "initialized" latch is set, but its data source is OFF (permanently stopped). Early-ready
+ // must not report READY off the latch alone; initialization must fail.
+ LDClientInterface client = mock(LDClientInterface.class);
+ when(client.getFlagTracker()).thenReturn(mock(FlagTracker.class));
+ DataSourceStatusProvider dsp = mock(DataSourceStatusProvider.class);
+ when(dsp.getStatus()).thenReturn(new DataSourceStatusProvider.Status(
+ DataSourceStatusProvider.State.OFF, null, null));
+ when(client.getDataSourceStatusProvider()).thenReturn(dsp);
+ when(client.isInitialized()).thenReturn(true);
+
+ Provider provider = new Provider(client, false);
+
+ assertThrows(RuntimeException.class, () -> provider.initialize(new ImmutableContext("user-key")));
+ assertEquals(ProviderState.ERROR, provider.getState());
+ }
}
From 1c96e6418632114cefc043a1af5ad4d1fe27676f Mon Sep 17 00:00:00 2001
From: Todd Anderson
Date: Mon, 2 Nov 2026 11:12:06 -0500
Subject: [PATCH 8/8] fix: Cancel initialization via the data source status
rather than at the provider
Reworks shutdown and initialization in response to review:
- shutdown() no longer completes the initialization future or removes
listeners itself. Closing the client transitions the data source to
OFF, which the still-registered status listener handles under the lock
to record the terminal state and end an indefinite initialization.
Cancellation runs through the same path as any other status change, so
there is no separate, unlocked write during shutdown.
- A client that has ever been initialized is reported READY again, rather
than being excluded when its data source is currently OFF; the
initialized latch is the signal that cached data can be served.
- The provider does not suppress ready/stale events during teardown; it
reflects the data source status the SDK reports. The shutdown flag is
only used to classify the client's own close as an expected teardown
rather than a provider error.
Listeners are registered inline and intentionally kept through close so
the OFF status can be observed. Tests updated accordingly.
---
.../openfeature/serverprovider/Provider.java | 41 ++++------------
.../serverprovider/LifeCycleTest.java | 49 ++++---------------
.../serverprovider/ProviderTest.java | 27 ----------
3 files changed, 18 insertions(+), 99 deletions(-)
diff --git a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
index f570168..b64bd15 100644
--- a/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
+++ b/src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
@@ -7,7 +7,6 @@
import com.launchdarkly.sdk.server.LDClient;
import com.launchdarkly.sdk.server.LDConfig;
import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider;
-import com.launchdarkly.sdk.server.interfaces.FlagChangeListener;
import com.launchdarkly.sdk.server.interfaces.LDClientInterface;
import dev.openfeature.sdk.*;
@@ -65,10 +64,6 @@ public String getName() {
// expected teardown rather than reported as a provider error.
private volatile boolean closed = false;
- // Registered in initialize() and removed in shutdown(). Held so they can be removed rather than leaked.
- private volatile FlagChangeListener flagChangeListener;
- private volatile DataSourceStatusProvider.StatusListener statusListener;
-
// 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);
@@ -246,20 +241,18 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
setInitializing(true);
boolean successfullyInitialized = false;
try {
- // The client's "initialized" latch stays set once it has received data, even while the data source is
- // interrupted; cached data can still be served, so that is reported READY. A data source which is OFF
- // is excluded: the client cannot recover from it, so it must not be reported as ready.
- DataSourceStatusProvider.Status currentStatus = client.getDataSourceStatusProvider().getStatus();
- if (client.isInitialized() && currentStatus.getState() != DataSourceStatusProvider.State.OFF) {
+ // 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.
- flagChangeListener = detail -> emitProviderConfigurationChanged(
- ProviderEventDetails.builder().flagsChanged(Collections.singletonList(detail.getKey())).build());
- client.getFlagTracker().addFlagChangeListener(flagChangeListener);
- statusListener = this::handleDataSourceStatus;
- client.getDataSourceStatusProvider().addStatusListener(statusListener);
+ // 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;
@@ -400,11 +393,6 @@ private void setState(ProviderState state) {
@Override
public void shutdown() {
closed = true;
- // Unblock an indefinite initialization that is still waiting. Under the retry-conformant SDK the data
- // source never reports OFF for a transport failure, so shutting down is, besides the data source becoming
- // valid, the only thing that ends the wait.
- initialized.complete(false);
- removeListeners();
try {
client.close();
} catch (IOException e) {
@@ -415,17 +403,6 @@ public void shutdown() {
}
}
- private void removeListeners() {
- var currentStatusListener = statusListener;
- if (currentStatusListener != null) {
- client.getDataSourceStatusProvider().removeStatusListener(currentStatusListener);
- }
- var currentFlagChangeListener = flagChangeListener;
- if (currentFlagChangeListener != null) {
- client.getFlagTracker().removeFlagChangeListener(currentFlagChangeListener);
- }
- }
-
@Override
public void track(String eventName, EvaluationContext ctx, TrackingEventDetails details) {
if (ctx == null) {
diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
index ddc3979..684c87b 100644
--- a/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
+++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/LifeCycleTest.java
@@ -567,7 +567,7 @@ public void aNullStartWaitTimeUsesTheDefaultAndDoesNotThrow() {
}
@Test
- public void shutdownEndsAnIndefiniteInitialization() throws Exception {
+ public void anOffDataSourceStatusCancelsAnIndefiniteInitialization() throws Exception {
var dataSourceFactory = new ControllableDataSourceFactory();
var config = new LDConfig.Builder()
.startWait(Duration.ZERO)
@@ -575,9 +575,11 @@ public void shutdownEndsAnIndefiniteInitialization() throws Exception {
.events(Components.noEvents())
.build();
var provider = new Provider("fake-key", config, Provider.INFINITE_START_WAIT);
- dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS);
+ var sink = dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS);
- // The data source never becomes valid, so an indefinite initialization would otherwise wait forever.
+ // 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 {
@@ -589,50 +591,17 @@ public void shutdownEndsAnIndefiniteInitialization() throws Exception {
});
initThread.start();
- // Let initialization park on the wait, then shut down to end it.
+ // Let initialization park on the wait, then deliver OFF.
Thread.sleep(200);
- provider.shutdown();
+ 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 aDirectProviderShutdownCancelsAnIndefiniteSetProviderAndWait() throws Exception {
- // The documented escape for an indefinite setProviderAndWait that never becomes ready: a direct
- // provider.shutdown() from another thread (OpenFeatureAPI.shutdown() would deadlock on the API lock
- // setProviderAndWait holds).
- 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);
- dataSourceFactory.sink.get(1000, TimeUnit.MILLISECONDS);
-
- CompletableFuture setProviderOutcome = new CompletableFuture<>();
- var waiter = new Thread(() -> {
- try {
- OpenFeatureAPI.getInstance().setProviderAndWait(provider);
- setProviderOutcome.complete(null);
- } catch (Throwable t) {
- setProviderOutcome.complete(t);
- }
- });
- waiter.start();
-
- // The data source never becomes valid, so setProviderAndWait is parked indefinitely.
- Thread.sleep(300);
- assertFalse(setProviderOutcome.isDone());
-
- provider.shutdown();
-
- var outcome = setProviderOutcome.get(2000, TimeUnit.MILLISECONDS);
- assertNotNull(outcome);
- }
-
@Test
public void aFailedBoundedInitializationSurfacesTheDataSourceError() throws Exception {
var dataSourceFactory = new ControllableDataSourceFactory();
diff --git a/src/test/java/com/launchdarkly/openfeature/serverprovider/ProviderTest.java b/src/test/java/com/launchdarkly/openfeature/serverprovider/ProviderTest.java
index c0d480c..65e3115 100644
--- a/src/test/java/com/launchdarkly/openfeature/serverprovider/ProviderTest.java
+++ b/src/test/java/com/launchdarkly/openfeature/serverprovider/ProviderTest.java
@@ -235,31 +235,4 @@ public void itCanTrackAnIntegerTrackingEventValue() {
valueConverter.toLdValue(new Value(trackingEventDetails)),
99.0);
}
-
- @Test
- public void shutdownRemovesTheStatusAndFlagListeners() throws Exception {
- ldProvider.initialize(new ImmutableContext("user-key"));
- ldProvider.shutdown();
-
- verify(mockedLdClient.getDataSourceStatusProvider()).removeStatusListener(any());
- verify(mockedLdClient.getFlagTracker()).removeFlagChangeListener(any());
- }
-
- @Test
- public void initializeFailsWhenTheClientIsInitializedButTheDataSourceIsOff() {
- // The client's "initialized" latch is set, but its data source is OFF (permanently stopped). Early-ready
- // must not report READY off the latch alone; initialization must fail.
- LDClientInterface client = mock(LDClientInterface.class);
- when(client.getFlagTracker()).thenReturn(mock(FlagTracker.class));
- DataSourceStatusProvider dsp = mock(DataSourceStatusProvider.class);
- when(dsp.getStatus()).thenReturn(new DataSourceStatusProvider.Status(
- DataSourceStatusProvider.State.OFF, null, null));
- when(client.getDataSourceStatusProvider()).thenReturn(dsp);
- when(client.isInitialized()).thenReturn(true);
-
- Provider provider = new Provider(client, false);
-
- assertThrows(RuntimeException.class, () -> provider.initialize(new ImmutableContext("user-key")));
- assertEquals(ProviderState.ERROR, provider.getState());
- }
}