Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, and a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. `Provider(String, LDConfig, Duration)` bounds initialization with a start wait duration; the other constructors wait until the data source becomes valid or permanently fails. |
| ✅ | 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. |
Expand Down Expand Up @@ -107,10 +107,14 @@ 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 provider supports Initialization and Shutdown using the OpenFeature API. Initialization starts as soon as the provider is constructed: the underlying LaunchDarkly SDK is created in the provider's constructor, and it blocks there for up to its configured start wait time.

OpenFeature will report when the provider is ready, and additionally the `setProviderAndWait` function of the OpenFeature
API can be used to wait until the provider is ready, or it has encountered a permanent error.
The `Provider(String, LDConfig, Duration)` constructor sets that start wait and bounds the whole of initialization with it. The provider's `initialize` does not wait a second time; it reports the outcome of that single wait, and fails if the client did not become ready in time. A non-zero duration is strongly recommended. A zero duration means the provider applies no deadline at all: the constructor does not block, and `initialize` waits until the data source becomes valid or fails permanently, which may be indefinitely if neither happens. The other constructors leave the start wait of the given `LDConfig` untouched and also wait indefinitely.

How initialization surfaces depends on how the provider is registered with the OpenFeature API:

- `setProviderAndWait` runs initialization on the calling thread, so the call blocks until the provider is ready or has permanently failed, and throws if it failed. With a zero duration this call can block indefinitely.
- `setProvider` runs initialization on a background thread and returns immediately. A failure is reported as a `PROVIDER_ERROR` event rather than thrown, and evaluations made before the provider is ready return their default value with the `PROVIDER_NOT_READY` error code.

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.
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 @@ -57,28 +59,65 @@ 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; using {@link Provider#Provider(String, LDConfig, Duration)} with a non-zero
* duration is strongly recommended.
*
* @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; using {@link Provider#Provider(String, LDConfig, Duration)} with a non-zero
* duration is strongly recommended.
*
* @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.
* <p>
* The duration is applied to the LaunchDarkly SDK as its start wait, and it bounds the whole of initialization:
* the constructor blocks for up to that long, and initialization then reports that outcome rather than waiting
* again. A non-zero duration is strongly recommended. A duration of zero applies no deadline: the constructor does
* not block, and initialization waits until the LaunchDarkly data source becomes valid or fails permanently, which
* may be indefinitely.
* <p>
* When the provider is registered with {@code setProviderAndWait}, initialization runs on the calling thread, so
* that call blocks for up to the duration and throws if the client did not become ready. When it is registered
* with {@code setProvider}, initialization runs in the background and a failure is reported as a
* {@code PROVIDER_ERROR} event instead, with evaluations before readiness returning their default value.
*
* @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 applies no deadline
*/
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 @@ -174,12 +213,19 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
boolean successfullyInitialized;
try {
handleDataSourceStatus(client.getDataSourceStatusProvider().getStatus(), completer);

// 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.");
}
successfullyInitialized = completer.get();
} finally {
setInitializing(false);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

if(!successfullyInitialized) {
if (!successfullyInitialized) {
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 @@ -159,6 +161,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 @@ -200,6 +222,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 @@ -288,6 +323,77 @@ 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();
}

@Test
public void itEmitsReadyWhenTheDataSourceRecoversFromAFailedInitialization() throws Exception {
var dataSourceFactory = new ControllableDataSourceFactory();
Expand Down
Loading