Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ 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`. The `Provider(String, LDConfig, Duration)` constructor bounds the whole of initialization with that duration: the constructor blocks for up to that long, and `initialize` then completes immediately with whatever the outcome was, failing if the client did not become ready in time. A zero duration means the provider waits indefinitely instead. The other constructors leave the config's start wait untouched, and the provider waits indefinitely for the data source to become valid or permanently fail.

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,6 +47,7 @@ public String getName() {
private final EvaluationContextConverter evaluationContextConverter;

private final LDClientInterface client;
private final Duration startWait;

private ProviderState state = ProviderState.NOT_READY;

Expand All @@ -55,28 +57,56 @@ public String getName() {
* Create a provider with the specified SDK and default configuration.
* <p>
* If you need to specify any configuration use {@link Provider#Provider(String, LDConfig)} instead.
* Initialization waits indefinitely; use {@link Provider#Provider(String, LDConfig, Duration)} when a bounded wait
* is wanted.
*
* @param sdkKey the SDK key for your LaunchDarkly environment
*/
public Provider(String sdkKey) {
this(sdkKey, new LDConfig.Builder().build());
this(new LDClient(sdkKey, withWrapper(new LDConfig.Builder().build())), Duration.ZERO);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Duration.Zero meaning wait indefinitely is not consistent with Duration.Zero passed to other SDKs startup/init/wait APIs. I think Duration.Zero is usually interpreted as don't wait at all.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero here came from OFP 4.3.4.2 — "If the configured start wait time is zero, the provider MUST NOT apply an initialization timeout" — with the rationale that zero means the application does not want to block on initialization and leaves how long to wait up to the caller.

Worth separating the two layers, because I think my javadoc is what actually reads wrong:

  • SDK layer: zero is passed straight through to LDConfig.startWait, so the LDClient constructor returns immediately. That is the "don't wait at all" behavior you'd expect.
  • Provider layer: initialize is invoked asynchronously by the OpenFeature API, so "no timeout" is not the application blocking forever — it means the provider does not fail initialization on a clock, and instead settles when the data source becomes valid or permanently fails. That is also the behavior on main today for every existing caller.

So the semantics follow the spec, but "waits indefinitely" is a misleading way to describe it, and if zero reads as "don't wait" to you it will read that way to users. Options, happy to take direction:

  1. Keep zero as the spec defines it and fix the wording to talk about not applying a timeout rather than waiting indefinitely.
  2. Make the no-timeout case a distinct value (null, or a negative duration) and let zero mean fail immediately if not already ready — this diverges from OFP 4.3.4.2, so it would want a spec change rather than just a provider change.

I'd lean towards 1 plus better docs, since the spec is cross-SDK, but you know the intent behind the wording better than I do. Which do you prefer?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, rephrasing it, we are saying:
This method will return immediately when the timeout is 0.
The time for the open feature initialized event itself is unbounded. It will be emitted when the SDK initializes or fails to initialize. If a timeout is provided, then an event will be emitted when the timeout lapses?

Or is it distinct from that?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close, with one correction on the first line — it's the constructor, not initialize, that returns immediately.

With Duration.ZERO:

  • The Provider constructor returns immediately. Zero goes to LDConfig.startWait, and the SDK checks isZero()/isNegative() and skips its wait on the data system future entirely.
  • initialize is unbounded: it blocks on the data source status until VALID (emits PROVIDER_READY) or OFF (emits PROVIDER_ERROR and throws). Nothing is emitted on a clock. Whether that blocks your thread is the OpenFeature API's choice, not the provider's — setProvider runs initialize on a background thread, setProviderAndWait blocks.

With a positive duration, both layers get it: the SDK constructor blocks up to that long, and initialize additionally stops waiting when it lapses, sets provider state to ERROR, and throws — the OpenFeature SDK wraps that in a GeneralError and emits PROVIDER_ERROR. So yes, an event on timeout, but as a failure rather than a separate timeout signal.

One consequence worth a decision: the status listener stays registered after that throw, so if the data source becomes valid later, the provider still emits PROVIDER_READY even though initialization already failed. I think that's desirable — it's how the SDK recovers on its own — but it does mean the timeout bounds initialization, not the provider's lifetime. Say the word if you'd rather a lapsed timeout be terminal.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on this after 095f4ea, since the behavior I described has changed: a positive duration is now spent once rather than twice. The SDK constructor blocks for up to startWait, and initialize then reports whatever the outcome already is instead of starting its own timed wait — so a two second start wait can no longer add up to four seconds of waiting. Zero still means no provider-applied timeout, per OFP 4.3.4.2, and initialize waits until the data source is valid or permanently fails.

The consequence I flagged above is unchanged: the status listener stays registered after a failed initialization, so a data source that becomes valid later still emits PROVIDER_READY. Still happy to make a lapsed start wait terminal instead if that's what you'd prefer.

}

/**
* Crate a provider with the specified SDK key and configuration.
* Create a provider with the specified SDK key and configuration.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
* Initialization waits indefinitely; use {@link Provider#Provider(String, LDConfig, Duration)} when a bounded wait
* is wanted.
*
* @param sdkKey the SDK key for your LaunchDarkly environment
* @param config a client configuration object
* @param config a client configuration object; its start wait setting is preserved, and provider initialization
* waits indefinitely
*/
public Provider(String sdkKey, LDConfig config) {
this(new LDClient(sdkKey, LDConfig.Builder.fromConfig(config)
this(new LDClient(sdkKey, withWrapper(config)), Duration.ZERO);
}

/**
* Create a provider with the specified SDK key, configuration, and start wait duration. The duration bounds the
* whole of initialization: the constructor blocks for up to that long, and initialization then completes with
* whatever the outcome was, rather than waiting again.
*
* @param sdkKey the SDK key for your LaunchDarkly environment
* @param config a client configuration object
* @param startWait the maximum duration to wait for initialization; zero means wait indefinitely
*/
public Provider(String sdkKey, LDConfig config, Duration startWait) {
this(new LDClient(sdkKey,
withWrapper(LDConfig.Builder.fromConfig(config).startWait(startWait).build())), startWait);
}

private static LDConfig withWrapper(LDConfig config) {
return LDConfig.Builder.fromConfig(config)
.wrapper(Components.wrapperInfo()
.wrapperName("open-feature-java-server")
.wrapperVersion(Version.SDK_VERSION)).build()));
.wrapperVersion(Version.SDK_VERSION)).build();
}

Provider(LDClientInterface client) {
this(client, Duration.ZERO);
}

Provider(LDClientInterface client, Duration startWait) {
this.client = client;
this.startWait = startWait;
logger = client.getLogger();
evaluationContextConverter = new EvaluationContextConverter(logger);
evaluationDetailConverter = new EvaluationDetailConverter(logger);
Expand Down Expand Up @@ -168,9 +198,15 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
}

handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer);
var successfullyInitialized = completer.get();

if(!successfullyInitialized) {
// With a start wait the client constructor has already waited, so the data source has either become valid,
// failed permanently, or run out of time; the outcome is whatever it is now.
if (!startWait.isZero() && !completer.isDone()) {
setState(ProviderState.ERROR);
throw new RuntimeException("The client did not initialize within the start wait duration.");
}

if (!completer.get()) {
throw new RuntimeException("Failed to initialize LaunchDarkly client.");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
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.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;

class DelayedDataSource implements DataSource {
Expand Down Expand Up @@ -98,6 +100,26 @@ public DataSource build(ClientContext clientContext) {
}
}

class NeverReadyDataSource implements DataSource {
public Future<Void> start() {
return new CompletableFuture<>();
}

public boolean isInitialized() {
return false;
}

public void close() throws IOException {
}
}

class NeverReadyDataSourceFactory implements ComponentConfigurer<DataSource> {
@Override
public DataSource build(ClientContext clientContext) {
return new NeverReadyDataSource();
}
}

/**
* Tests in this suite use a real client instance and the public constructor.
* <p>
Expand Down Expand Up @@ -139,6 +161,19 @@ public void canShutdownAnOfflineClient() {
});
}

@Test
public void twoArgumentConstructorPreservesConfigStartWait() {
assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
var config = new LDConfig.Builder()
.startWait(Duration.ZERO)
.dataSource(new NeverReadyDataSourceFactory())
.events(Components.noEvents())
.build();
var provider = new Provider("fake-key", config);
provider.shutdown();
});
}

@Test
public void itEmitsReadyEvents() throws ExecutionException, InterruptedException, TimeoutException {
var provider = new Provider("fake-key", new LDConfig.Builder()
Expand Down Expand Up @@ -221,4 +256,75 @@ public void itCanHandleClientThatIsNotInitializedImmediatelyAndErrors() throws E

assertTrue(gotErrorEvent.get(1000, TimeUnit.MILLISECONDS));
}

@Test
public void initializationFailsWithoutWaitingAgainWhenStartWaitIsPositive() {
assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
var config = new LDConfig.Builder()
.dataSource(new NeverReadyDataSourceFactory())
.events(Components.noEvents())
.build();
var provider = new Provider("fake-key", config, Duration.ofMillis(300));
try {
// The constructor consumed the start wait, so initialization must not wait a second time.
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
var error = assertThrows(RuntimeException.class,
() -> provider.initialize(new ImmutableContext("context-key")));
assertTrue(error.getMessage()
.contains("The client did not initialize within the start wait duration."));
});
assertEquals(ProviderState.ERROR, provider.getState());
} finally {
provider.shutdown();
}
});
}

@Test
public void initializationSucceedsWhenTheClientBecomesReadyDuringTheStartWait() {
assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
var config = new LDConfig.Builder()
.dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), false))
.events(Components.noEvents())
.build();
var provider = new Provider("fake-key", config, Duration.ofMillis(500));
try {
assertDoesNotThrow(() -> provider.initialize(new ImmutableContext("context-key")));
assertEquals(ProviderState.READY, provider.getState());
} finally {
provider.shutdown();
}
});
}

@Test
public void initializationReportsPermanentFailureAfterAPositiveStartWait() {
assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
var config = new LDConfig.Builder()
.dataSource(new DelayedDataSourceFactory(Duration.ofMillis(100), true))
.events(Components.noEvents())
.build();
var provider = new Provider("fake-key", config, Duration.ofMillis(500));
try {
var error = assertThrows(RuntimeException.class,
() -> provider.initialize(new ImmutableContext("context-key")));
assertEquals("Failed to initialize LaunchDarkly client.", error.getMessage());
} finally {
provider.shutdown();
}
});
}

@Test
public void initializationWaitsIndefinitelyWhenStartWaitIsZero() throws Exception {
var config = new LDConfig.Builder()
.dataSource(new DelayedDataSourceFactory(Duration.ofMillis(200), false))
.events(Components.noEvents())
.build();
var provider = new Provider("fake-key", config, Duration.ZERO);

assertDoesNotThrow(() -> provider.initialize(new ImmutableContext("context-key")));
assertEquals(ProviderState.READY, provider.getState());
provider.shutdown();
}
}
Loading