Skip to content

Commit 8bff7b3

Browse files
fix!: Use None rather than zero for indefinite initialization waiting (#64)
Aligns `start_wait` with the OpenFeature provider spec (OFP), which requires a distinct non-duration value to request indefinite initialization waiting. - `start_wait=None` now requests an indefinite wait: the constructor does not block and `initialize` waits until the data source becomes valid or fails permanently. - `start_wait=0` no longer waits indefinitely; it waits nowhere, so `initialize` reports a failed initialization unless the client is already ready, and readiness is observed through provider events. - Positive values are unchanged: the constructor bounds the wait and `initialize` returns immediately with whatever the outcome is. - Breaking for anyone relying on `0` meaning "wait forever". **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** None. <details> <summary>Implementation details</summary> OFP requires that a positive start wait bound total construction plus initialization, that zero wait nowhere, and that indefinite waiting avoid construction-time blocking and instead wait during `initialize`. Previously zero was overloaded to mean indefinite, leaving no way to express "no waiting at all". `None` is now passed to `LDClient` as a zero construction wait, with the waiting performed in `initialize` against the data source status listener. Testing: `make test` (88 tests) and `make lint` (mypy) pass. New tests cover the zero and indefinite cases, including that the constructor does not block when `None` is given. </details> Link to Devin session: https://app.devin.ai/sessions/1fd3fcbfe79f482e8b58be29e8df2ffb Open in Devin Desktop: https://app.devin.ai/desktop/session/1fd3fcbfe79f482e8b58be29e8df2ffb?variant=devin Requested by: @kinyoklion [SDK-3285](https://launchdarkly.atlassian.net/browse/SDK-3285) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Breaking change:** `start_wait` now matches the OpenFeature provider contract instead of overloading `0` for “wait forever.” > > `start_wait=None` means indefinite waiting only inside `initialize` (constructor passes `0` to `LDClient` so it does not block). `start_wait=0` means no waiting anywhere—`initialize` fails immediately unless the client is already ready, and later readiness is surfaced via provider events. Positive values are unchanged. > > Provider lifecycle updates track `ProviderStatus`, register data-source listeners before the readiness check, suppress duplicate events during initialization, and still emit `PROVIDER_READY` if the data source becomes valid after a failed init. README marks initialization fully supported; tests add `DelayedReadyDataSource` and cases for zero vs `None` and post-failure ready events. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9dc4c47. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [SDK-3285]: https://launchdarkly.atlassian.net/browse/SDK-3285?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent caef880 commit 8bff7b3

4 files changed

Lines changed: 131 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Python](https
3939
| ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. |
4040
| ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE` and `PROVIDER_ERROR`, and only when the status changes; flag changes as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. |
4141
| ✅ | Tracking | `track` sends a LaunchDarkly custom event for the evaluation context, with the tracking event value and remaining details attached. |
42-
| ⚠️ | Initialization | `initialize` reports whether the LaunchDarkly client became ready. The optional `start_wait` parameter bounds initialization; zero applies no timeout and waits until the data source becomes valid or permanently fails. Since LaunchDarkly Python SDK 9.18.0 a rejected SDK key is retried rather than failing permanently, so zero can wait indefinitely. Provider gap: zero should not wait, [#64](https://github.com/launchdarkly/openfeature-python-server/pull/64). |
42+
| ✅ | Initialization | `initialize` reports whether the LaunchDarkly client became ready. The optional `start_wait` parameter bounds initialization; `None` waits until the data source becomes valid or permanently fails. Since LaunchDarkly Python SDK 9.18.0 a rejected SDK key is retried rather than failing permanently, so `None` can wait indefinitely. |
4343
| ✅ | Shutdown | `shutdown` closes the LaunchDarkly client; a closed client cannot be restarted, so a new provider instance is required afterward. |
4444
| ✅ | 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. |
4545
| ✅ | Extending | The underlying LaunchDarkly client is available through the `client` property. |
@@ -70,7 +70,7 @@ api.set_provider(openfeature_provider)
7070
# Refer to OpenFeature documentation for getting a client and performing evaluations.
7171
```
7272

73-
The optional `start_wait` parameter is the number of seconds to wait for a successful connection to LaunchDarkly, matching the same parameter of the LaunchDarkly SDK's `LDClient`, and defaulting to the same five seconds. A positive value bounds the whole of initialization: the provider constructor blocks for up to that long, and OpenFeature initialization then completes immediately, reporting a failed initialization if the client did not become ready in time. Zero does not block the constructor at all, and initialization then waits without a deadline for the data source to become valid or to fail permanently. Since LaunchDarkly Python SDK 9.18.0 a rejected SDK key is retried rather than treated as a permanent failure, so with zero, initialization does not complete while the key is rejected.
73+
The optional `start_wait` parameter is the number of seconds to wait for a successful connection to LaunchDarkly, matching the same parameter of the LaunchDarkly SDK's `LDClient`, and defaulting to the same five seconds. A positive value bounds the whole of initialization: the provider constructor blocks for up to that long, and OpenFeature initialization then completes immediately, reporting a failed initialization if the client did not become ready in time. Zero waits nowhere, so initialization fails unless the client is already ready, and the application learns when the client becomes usable from provider events. `None` requests waiting indefinitely: the constructor does not block, and initialization waits without a deadline for the data source to become valid or to fail permanently. Since LaunchDarkly Python SDK 9.18.0 a rejected SDK key is retried rather than treated as a permanent failure, so with `None`, initialization does not complete while the key is rejected.
7474

7575
`api.set_provider` registers the provider without waiting for it to initialize, so the first evaluations may use fallback values while the LaunchDarkly client is still connecting. Use `api.set_provider_and_wait` instead when the application needs registration to block until initialization has succeeded or failed.
7676

‎ld_openfeature/provider.py‎

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, FlagValueType, Reason
1111
from openfeature.hook import Hook
1212
from openfeature.provider.metadata import Metadata
13-
from openfeature.provider import AbstractProvider
13+
from openfeature.provider import AbstractProvider, ProviderStatus
1414
from openfeature.event import ProviderEventDetails
1515
from openfeature.track import TrackingEventDetails
1616

@@ -26,26 +26,28 @@
2626

2727

2828
class LaunchDarklyProvider(AbstractProvider):
29-
def __init__(self, config: Config, start_wait: float = 5):
29+
def __init__(self, config: Config, start_wait: Optional[float] = 5):
3030
"""
3131
Create a provider backed by a LaunchDarkly client.
3232
3333
:param config: The LaunchDarkly client configuration.
3434
:param start_wait: The number of seconds to wait for a successful connection to LaunchDarkly, matching
3535
the same parameter of :class:`ldclient.LDClient`. A positive value bounds the whole of initialization:
3636
this constructor blocks for up to that long, and ``initialize`` then completes immediately, reporting
37-
a failed initialization if the client did not become ready in time. Zero does not block this
38-
constructor at all, and ``initialize`` then waits without a deadline for the data source to become
39-
valid or to fail permanently.
37+
a failed initialization if the client did not become ready in time. Zero waits nowhere, so
38+
``initialize`` reports a failed initialization unless the client is already ready. ``None`` waits
39+
only in ``initialize``, without a deadline, until the data source becomes valid or fails permanently.
4040
"""
41-
self.__client = LDClient(config.with_wrapper_information(WRAPPER_NAME, VERSION), start_wait)
41+
self.__client = LDClient(config.with_wrapper_information(WRAPPER_NAME, VERSION),
42+
0 if start_wait is None else start_wait)
4243
self.__start_wait = start_wait
44+
self.__initialization_complete = False
45+
self.__status_lock = threading.Lock()
46+
self.__provider_status = ProviderStatus.NOT_READY
4347

4448
self.__context_converter = EvaluationContextConverter()
4549
self.__details_converter = ResolutionDetailsConverter()
4650

47-
self.__status_lock = threading.Lock()
48-
self.__last_emitted_state: Optional[DataSourceState] = None
4951

5052
@property
5153
def client(self) -> LDClient:
@@ -56,39 +58,40 @@ def client(self) -> LDClient:
5658
"""
5759
return self.__client
5860

59-
def __is_new_status(self, state: DataSourceState) -> bool:
61+
def __set_status(self, status: ProviderStatus) -> bool:
6062
"""
61-
Report whether a state changes the provider status. Several data source states can map to the same provider
62-
status, and a repeated status is not a change an application can act on.
63+
Record the provider status and report whether it changed, and whether the change should be emitted.
64+
65+
The OpenFeature client emits an event for the status change which completes initialization, so that
66+
change is not emitted here. Changes after initialization has completed, including after it has failed,
67+
are emitted.
6368
"""
6469
with self.__status_lock:
65-
if state == self.__last_emitted_state:
70+
if status == self.__provider_status:
6671
return False
67-
68-
self.__last_emitted_state = state
69-
return True
72+
self.__provider_status = status
73+
return self.__initialization_complete
7074

7175
def __handle_data_source_status(self, status: DataSourceStatus):
7276
state = status.state
7377
if state == DataSourceState.INITIALIZING:
7478
return
75-
76-
if not self.__is_new_status(state):
77-
return
78-
79-
if state == DataSourceState.VALID:
80-
self.emit_provider_ready(ProviderEventDetails())
79+
elif state == DataSourceState.VALID:
80+
if self.__set_status(ProviderStatus.READY):
81+
self.emit_provider_ready(ProviderEventDetails())
8182
elif state == DataSourceState.OFF:
8283
error_message = self.__get_message(status,
8384
"the provider has encountered a permanent error or has been shutdown")
8485
# This is not reported as a fatal error. A fatal provider prevents the OpenFeature client
8586
# from evaluating flags at all, but the LaunchDarkly client can keep evaluating the flag
8687
# data it already has.
87-
self.emit_provider_error(ProviderEventDetails(error_code=ErrorCode.GENERAL,
88-
message=error_message))
88+
if self.__set_status(ProviderStatus.ERROR):
89+
self.emit_provider_error(ProviderEventDetails(error_code=ErrorCode.GENERAL,
90+
message=error_message))
8991
elif state == DataSourceState.INTERRUPTED:
9092
error_message = self.__get_message(status, "encountered an unknown error")
91-
self.emit_provider_stale(ProviderEventDetails(message=error_message))
93+
if self.__set_status(ProviderStatus.STALE):
94+
self.emit_provider_stale(ProviderEventDetails(message=error_message))
9295

9396
# For now treat an unknown state as no change.
9497

@@ -105,7 +108,11 @@ def ready_handler(status: DataSourceStatus):
105108
elif status.state == DataSourceState.OFF:
106109
ready_event.set()
107110

108-
# We listen just to handle the ready event. We do not emit events because the client emits them for us.
111+
# We listen for status changes before checking the current state, so that a change in between is not
112+
# missed. Emission is suppressed until initialization has completed, so the listeners stay registered
113+
# even when initialization fails and the client later recovers.
114+
self.__client.data_source_status_provider.add_listener(self.__handle_data_source_status)
115+
self.__client.flag_tracker.add_listener(self.__handle_flag_change)
109116
self.__client.data_source_status_provider.add_listener(ready_handler)
110117

111118
# Check for conditions that may have happened before we added the listener.
@@ -116,17 +123,22 @@ def ready_handler(status: DataSourceStatus):
116123
ready_event.set()
117124

118125
# With a start wait the client constructor has already waited, so the outcome is whatever it is now.
119-
if self.__start_wait <= 0:
126+
if self.__start_wait is None:
120127
ready_event.wait()
121128

122129
self.__client.data_source_status_provider.remove_listener(ready_handler)
123130

124-
if not self.__client.is_initialized():
125-
raise ProviderNotReadyError(error_message="launchdarkly client initialization failed")
131+
# The OpenFeature client reports the outcome of initialization itself, so the provider records the
132+
# resulting status without emitting an event for it. The outcome is read and recorded, and initialization
133+
# is completed, under the lock, so a concurrent status change either precedes the outcome it is included
134+
# in or is emitted.
135+
with self.__status_lock:
136+
initialized = self.__client.is_initialized()
137+
self.__provider_status = ProviderStatus.READY if initialized else ProviderStatus.ERROR
138+
self.__initialization_complete = True
126139

127-
# Listen to new status events and emit them.
128-
self.__client.data_source_status_provider.add_listener(self.__handle_data_source_status)
129-
self.__client.flag_tracker.add_listener(self.__handle_flag_change)
140+
if not initialized:
141+
raise ProviderNotReadyError(error_message="launchdarkly client initialization failed")
130142

131143
def shutdown(self):
132144
self.__client.data_source_status_provider.remove_listener(self.__handle_data_source_status)

‎tests/test_data_sources.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ def initialized(self):
9191
return False
9292

9393

94+
class DelayedReadyDataSource(UpdateProcessor):
95+
def __init__(self, config: Config, store, ready: threading.Event):
96+
self._data_source_update_sink: Optional[DataSourceUpdateSink] = config.data_source_update_sink
97+
self._ready = ready
98+
self._initialized = False
99+
100+
def start(self):
101+
if self._data_source_update_sink is None:
102+
return
103+
104+
def data_source_ready():
105+
self._data_source_update_sink.init({FEATURES: {}})
106+
self._initialized = True
107+
self._data_source_update_sink.update_status(DataSourceState.VALID, None)
108+
self._ready.set()
109+
110+
threading.Timer(0.1, data_source_ready).start()
111+
112+
def stop(self):
113+
pass
114+
115+
def is_alive(self):
116+
return True
117+
118+
def initialized(self):
119+
return self._initialized
120+
121+
94122
class InitializedThenFailingDataSource(UpdateProcessor):
95123
def __init__(self, config: Config, store, ready: threading.Event):
96124
self._data_source_update_sink: Optional[DataSourceUpdateSink] = config.data_source_update_sink

‎tests/test_provider.py‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from openfeature import api
1717

1818
from ld_openfeature import LaunchDarklyProvider, Config
19-
from tests.test_data_sources import FailingDataSource, InitializedThenFailingDataSource, NeverReadyDataSource, RepeatedlyInterruptedDataSource, StaleDataSource, UpdatingDataSource, DelayedFailingDataSource
19+
from tests.test_data_sources import FailingDataSource, InitializedThenFailingDataSource, NeverReadyDataSource, RepeatedlyInterruptedDataSource, StaleDataSource, UpdatingDataSource, DelayedFailingDataSource, DelayedReadyDataSource
2020
from ld_openfeature.version import VERSION
2121

2222

@@ -73,6 +73,63 @@ def test_initialization_fails_without_waiting_again_with_positive_start_wait():
7373
provider.shutdown()
7474

7575

76+
def test_zero_start_wait_does_not_wait_for_the_data_source():
77+
started = time.time()
78+
provider = LaunchDarklyProvider(
79+
Config("", update_processor_class=NeverReadyDataSource, send_events=False),
80+
start_wait=0,
81+
)
82+
83+
with pytest.raises(ProviderNotReadyError):
84+
provider.initialize(EvaluationContext("user-key"))
85+
86+
assert time.time() - started < 0.25
87+
provider.shutdown()
88+
89+
90+
def test_no_start_wait_waits_for_the_data_source_during_initialization():
91+
started = time.time()
92+
provider = LaunchDarklyProvider(
93+
Config("", update_processor_class=DelayedReadyDataSource, send_events=False),
94+
start_wait=None,
95+
)
96+
construction_finished = time.time()
97+
98+
provider.initialize(EvaluationContext("user-key"))
99+
100+
assert construction_finished - started < 0.1
101+
assert time.time() - started >= 0.1
102+
provider.shutdown()
103+
104+
105+
def test_ready_is_reported_after_initialization_failed():
106+
thread_event = threading.Event()
107+
emission_count = 0
108+
109+
def handle_status(details: EventDetails):
110+
nonlocal emission_count
111+
if details.provider_name == 'launchdarkly-openfeature-server':
112+
emission_count += 1
113+
thread_event.set()
114+
115+
api.add_handler(ProviderEvent.PROVIDER_READY, handle_status)
116+
117+
provider = LaunchDarklyProvider(
118+
Config("", update_processor_class=DelayedReadyDataSource, send_events=False),
119+
start_wait=0,
120+
)
121+
122+
# Initialization fails because the data source is not ready yet, and the provider reports the later
123+
# transition to a valid data source.
124+
api.set_provider(provider)
125+
126+
assert thread_event.wait(timeout=5)
127+
time.sleep(0.1)
128+
assert emission_count == 1
129+
130+
api.shutdown()
131+
132+
76133
def test_provider_identifies_itself_as_the_wrapper(provider: LaunchDarklyProvider, config: Config):
77134
assert provider.client._config.wrapper_name == "open-feature-python-server"
78135
assert provider.client._config.wrapper_version == VERSION

0 commit comments

Comments
 (0)