feat: Add a start wait timeout for initialization - #61
Conversation
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
@cursor review |
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
| */ | ||
| public Provider(String sdkKey) { | ||
| this(sdkKey, new LDConfig.Builder().build()); | ||
| this(new LDClient(sdkKey, withWrapper(new LDConfig.Builder().build())), Duration.ZERO); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 theLDClientconstructor returns immediately. That is the "don't wait at all" behavior you'd expect. - Provider layer:
initializeis 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 onmaintoday 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:
- Keep zero as the spec defines it and fix the wording to talk about not applying a timeout rather than waiting indefinitely.
- 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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Close, with one correction on the first line — it's the constructor, not initialize, that returns immediately.
With Duration.ZERO:
- The
Providerconstructor returns immediately. Zero goes toLDConfig.startWait, and the SDK checksisZero()/isNegative()and skips its wait on the data system future entirely. initializeis unbounded: it blocks on the data source status untilVALID(emitsPROVIDER_READY) orOFF(emitsPROVIDER_ERRORand throws). Nothing is emitted on a clock. Whether that blocks your thread is the OpenFeature API's choice, not the provider's —setProviderrunsinitializeon a background thread,setProviderAndWaitblocks.
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.
There was a problem hiding this comment.
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.
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
…rt wait Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
Adds a `start_wait` parameter to `LaunchDarklyProvider`, defaulting to the LaunchDarkly SDK's five seconds. Closes [#55](#55). - The value is passed to `LDClient(config, start_wait)` and bounds the whole of initialization once: with a positive value `initialize` reports the outcome the constructor already waited for rather than waiting again, so a five second start wait cannot become a ten second wait for `set_provider_and_wait`. - Initialization fails when the client did not become ready in time; the provider keeps reporting status afterward, so a later connection still makes it ready. - Zero does not block the constructor at all, and `initialize` then waits without a deadline for the data source to become valid or to fail permanently. - Flips the README feature matrix's Initialization row to supported, since this is the change that makes it true. <details> <summary>Implementation details</summary> ```python # With a start wait the client constructor has already waited, so the outcome is whatever it is now. if self.__start_wait <= 0: ready_event.wait() ``` **Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions **Related issues** [#55](#55), and the matching Java change in [openfeature-java-server#61](launchdarkly/openfeature-java-server#61). Related spec change: [sdk-specs#257](launchdarkly/sdk-specs#257). **Describe the solution you've provided** `main` has since merged [#56](#56), [#57](#57) and [#58](#58); those are merged into this branch, so `start_wait` composes with the wrapper information now passed to `LDClient`, and a failed initialization raises `ProviderNotReadyError` rather than a fatal error. **Describe alternatives you've considered** Waiting on the ready event for `start_wait` seconds inside `initialize` as well: that doubled the effective wait, since the client constructor had already waited the same amount. **Additional context** Testing: `make test` (84 passed) and `make lint`. Tests cover the default matching the SDK default and initialization failing without waiting a second time; the timing assertion fails rather than hanging CI if the double wait comes back. </details> Link to Devin session: https://app.devin.ai/sessions/38a6eaf69fcf41109e136a1d0fe5e899 Open in Devin Desktop: https://app.devin.ai/desktop/session/38a6eaf69fcf41109e136a1d0fe5e899?variant=devin Requested by: @kinyoklion <!-- CURSOR_SUMMARY --> --- > [!NOTE] > <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a summary for commit 69390f7. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…-start-wait Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a40a6d7. Configure here.
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
…rAndWait Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>

initializeblocked onCompletableFuture.get()with no timeout, so a provider whose data source never became valid and never permanently failed would wait forever.Closes #58.
Provider(String sdkKey, LDConfig config, Duration startWait), which applies the duration to the SDK throughLDConfig.startWaitinitializethen reports whatever the outcome already is instead of waiting a second timeERRORandinitializethrows, distinct from the existing initialization-failure pathinitializewaits until the data source is valid or permanently failedLDConfig.startWaituntouched, so a caller who configuredstartWaitthemselves is not overriddenLDConfig.startWaitrather than inventing a cross-provider nameImplementation details
Requirements
Related issues
#58
Describe the solution you've provided
LDClientonly exposes one- and two-argument constructors andLDConfighas no start-wait getter, so the three-argument constructor applies the duration throughLDConfig.Builder.startWait(...)and keeps its own copy. Because the SDK constructor has already consumed that budget by the timeinitializeruns,initializetreats an unfinished initialization as a lapsed start wait rather than waiting again — a single wait, not two back-to-back ones. It keys onisDone()rather than agetNowdefault so that a data source which has already reported a permanent failure is reported as that failure and not as a timeout.The lack of a start-wait getter is also why the two-argument constructor stays indefinite rather than adopting a default: it cannot discover a start wait the caller configured, so defaulting would silently override it.
This branch also carries the merge of
mainafter #59, #60 and #63 landed. The start-wait check sits inside theinitializingwindow that #63 introduced, so the state reset still happens on the timeout path and no ready event is emitted from the provider during initialization.Describe alternatives you've considered
Making the existing constructors default to the SDK's five second start wait would have given every caller a timeout without an API change, but it overrides a
startWaitthe caller set on their ownLDConfig— which the README tells them to do — so it was rejected as a silent behavior change.Having
initializerun its own timed wait on top of the SDK's was the first implementation; it meant a caller passing a two second start wait could wait four seconds in total, so the provider now reuses the wait the SDK already performed.Additional context
Testing:
./gradlew test checkstyleMain javadoc— all tests, checkstyle and javadoc pass. Lifecycle tests cover a positive start wait failing without waiting again against a data source that never becomes ready, a client that becomes ready during the start wait, a zero start wait waiting indefinitely, the two-argument constructor preserving a configured start wait, and the error-event and ready-suppression tests from #59 and #63.Link to Devin session: https://app.devin.ai/sessions/38a6eaf69fcf41109e136a1d0fe5e899
Open in Devin Desktop: https://app.devin.ai/desktop/session/38a6eaf69fcf41109e136a1d0fe5e899?variant=devin
Requested by: @kinyoklion
Note
Overview
Adds
Provider(String, LDConfig, Duration)so callers can cap how long LaunchDarkly client startup may take. The duration is applied viaLDConfig.startWait, the constructor blocks up to that limit, andinitializereuses that single wait—it does not block again. If the client is still not ready after a non-zero start wait, the provider moves toERRORand throws a distinct timeout message (separate from permanent data-source failure).Duration.ZEROkeeps the prior indefinite behavior: no constructor blocking andinitializewaits until the data source is valid or permanently fails. The one- and two-argument constructors are unchanged in semantics; they now route through a sharedwithWrapperhelper and still use zero provider-level deadline without overridingLDConfig.startWaiton the two-arg path.README marks Initialization as fully supported and documents
setProvidervssetProviderAndWait. Lifecycle tests cover timeout without double-wait, success within the window, permanent failure, zero duration, and preserving config start wait.Reviewed by Cursor Bugbot for commit 3955f1d. Bugbot is set up for automated code reviews on this repo. Configure here.