diff --git a/README.md b/README.md index a47bf55..ba6d05c 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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/). diff --git a/ld_openfeature/provider.py b/ld_openfeature/provider.py index 9c5275c..d01a6fb 100644 --- a/ld_openfeature/provider.py +++ b/ld_openfeature/provider.py @@ -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() @@ -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() self.__client.data_source_status_provider.remove_listener(ready_handler) diff --git a/tests/test_data_sources.py b/tests/test_data_sources.py index 2440083..1deadb1 100644 --- a/tests/test_data_sources.py +++ b/tests/test_data_sources.py @@ -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 diff --git a/tests/test_provider.py b/tests/test_provider.py index 3bb85af..4183949 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -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 @@ -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