Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Python](https
| ✅ | 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 as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. |
| ✅ | Tracking | `track` sends a LaunchDarkly custom event for the evaluation context, with the tracking event value and remaining details attached. |
| ⚠️ | Initialization | `initialize` reports whether the LaunchDarkly client became ready. It has no timeout of its own and waits until the data source becomes valid or permanently fails: [#55](https://github.com/launchdarkly/openfeature-python-server/issues/55). |
| | 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. |
| ✅ | 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 | The underlying LaunchDarkly client is available through the `client` property. |
Expand Down Expand Up @@ -70,6 +70,8 @@ api.set_provider(openfeature_provider)
# Refer to OpenFeature documentation for getting a client and performing evaluations.
```

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.

Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side/python) for instructions on getting started with using the SDK.

For information on using the OpenFeature client please refer to the [OpenFeature Documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api/).
Expand Down
20 changes: 17 additions & 3 deletions ld_openfeature/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,20 @@


class LaunchDarklyProvider(AbstractProvider):
def __init__(self, config: Config):
self.__client = LDClient(config.with_wrapper_information(WRAPPER_NAME, VERSION))
def __init__(self, config: Config, start_wait: float = 5):
"""
Create a provider backed by a LaunchDarkly client.

:param config: The LaunchDarkly client configuration.
:param start_wait: The number of seconds to wait for a successful connection to LaunchDarkly, matching
the same parameter of :class:`ldclient.LDClient`. A positive value bounds the whole of initialization:
this constructor blocks for up to that long, and ``initialize`` then completes immediately, reporting
a failed initialization if the client did not become ready in time. Zero does not block this
constructor at all, and ``initialize`` then waits without a deadline for the data source to become
valid or to fail permanently.
"""
self.__client = LDClient(config.with_wrapper_information(WRAPPER_NAME, VERSION), start_wait)
self.__start_wait = start_wait

self.__context_converter = EvaluationContextConverter()
self.__details_converter = ResolutionDetailsConverter()
Expand Down Expand Up @@ -84,7 +96,9 @@ def ready_handler(status: DataSourceStatus):
if self.__client.is_initialized():
ready_event.set()

ready_event.wait()
# 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Timeout drops later ready events

Medium Severity

When start_wait expires, initialize raises ProviderNotReadyError before attaching the data-source and flag listeners. A later LaunchDarkly connection never emits PROVIDER_READY, so OpenFeature stays in ERROR and configuration-change events are also dropped.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 69390f7. Configure here.

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.

This is correct, and it contradicts a bullet in the description above (now fixed): the raise happens before the status and flag-change listeners are attached, so a provider whose start wait lapses stays silent — no PROVIDER_READY when the connection later succeeds, and no configuration-changed events either. It isn't new to this PR (the same was true of the pre-existing is_initialized() failure path), but a start wait makes it reachable from mere slowness rather than only from a permanent failure.

Not changing it here, since it's the same open question as on openfeature-java-server#61: whether a lapsed start wait is terminal for the provider or just a failed initialization it can recover from. Attaching the listeners before the raise is the fix if it's the latter, and it should be decided the same way in both languages.


self.__client.data_source_status_provider.remove_listener(ready_handler)

Expand Down
17 changes: 17 additions & 0 deletions tests/test_data_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ def initialized(self):
return False


class NeverReadyDataSource(UpdateProcessor):
def __init__(self, config: Config, store, ready: threading.Event):
self._ready = ready

def start(self):
pass

def stop(self):
pass

def is_alive(self):
return False

def initialized(self):
return False


class DelayedFailingDataSource(UpdateProcessor):
def __init__(self, config: Config, store, ready: threading.Event):
self._data_source_update_sink: Optional[DataSourceUpdateSink] = config.data_source_update_sink
Expand Down
25 changes: 24 additions & 1 deletion tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
from openfeature import api

from ld_openfeature import LaunchDarklyProvider, Config
from tests.test_data_sources import FailingDataSource, InitializedThenFailingDataSource, NeverReadyDataSource, StaleDataSource, UpdatingDataSource, DelayedFailingDataSource
from ld_openfeature.version import VERSION
from tests.test_data_sources import FailingDataSource, InitializedThenFailingDataSource, StaleDataSource, UpdatingDataSource, DelayedFailingDataSource


@pytest.fixture
Expand Down Expand Up @@ -50,6 +50,29 @@ def test_ldclient_is_accessible(provider: LaunchDarklyProvider):
assert type(provider.client) is LDClient


def test_default_start_wait_matches_launchdarkly_sdk_default():
config = Config("", offline=True)

with patch("ld_openfeature.provider.LDClient") as client:
LaunchDarklyProvider(config)

assert client.call_args.args[1] == 5


def test_initialization_fails_without_waiting_again_with_positive_start_wait():
provider = LaunchDarklyProvider(
Config("", update_processor_class=NeverReadyDataSource, send_events=False),
start_wait=0.5,
)

started = time.time()
with pytest.raises(ProviderNotReadyError):
provider.initialize(EvaluationContext("user-key"))

assert time.time() - started < 0.25
provider.shutdown()


def test_provider_identifies_itself_as_the_wrapper(provider: LaunchDarklyProvider, config: Config):
assert provider.client._config.wrapper_name == "open-feature-python-server"
assert provider.client._config.wrapper_version == VERSION
Expand Down
Loading