From aefff18196a134a07e67a05c4cfe18ffb83eb1c8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:57:58 +0000 Subject: [PATCH 1/3] feat!: Add a start wait time option for initialization Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- README.md | 16 ++++-- .../Provider.cs | 48 +++++++++++++++- .../ProviderTests.cs | 57 +++++++++++++++++++ 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 31c880f..056af98 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for .NET](https:/ | ✅ | Logging | The provider logs through the logging configuration of the `Configuration` 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 | `InitializeAsync` waits for the LaunchDarkly client to become ready or to fail permanently. It has no timeout of its own; `StartWaitTime` applies to the client constructor. | +| ✅ | Initialization | `InitializeAsync` reports whether the LaunchDarkly client became ready within the start wait time, which is `StartWaitTime` or the start wait time given to the provider constructor. A null start wait time waits indefinitely. | | ✅ | Shutdown | `ShutdownAsync` 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 `GetClient()` for functionality with no OpenFeature equivalent. | @@ -192,12 +192,18 @@ var inExperiment = details.FlagMetadata.GetBool("inExperiment") ?? false; #### Asynchronous Initialization -The LaunchDarkly SDK by default blocks on construction for up to 5 seconds for initialization. If you require construction to be non-blocking, then you can adjust the `startWaitTime` to `TimeSpan.Zero`. Initialization will be completed asynchronously and OpenFeature will emit a ready event when the provider has initialized. The `SetProviderAsync` method can be awaited to wait for the SDK to finish initialization. +The LaunchDarkly SDK by default blocks on construction for up to 5 seconds for initialization, and `SetProviderAsync` then reports whether the client became ready in that time. + +If you require construction to be non-blocking, then pass `TimeSpan.Zero` as the start wait time. Nothing is waited for, so `SetProviderAsync` reports a failed initialization, and OpenFeature emits a ready event once the provider becomes usable. ```csharp -var config = Configuration.Builder("my-sdk-key") - .StartWaitTime(TimeSpan.Zero) - .Build(); +var provider = new Provider(config, TimeSpan.Zero); +``` + +To wait for the provider to become ready without a deadline, pass a null start wait time. Construction does not block, and `SetProviderAsync` completes once the data source becomes valid or fails permanently. + +```csharp +var provider = new Provider(config, null); ``` #### Provider Shutdown diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 971d3c7..0ddf9c7 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -33,6 +33,7 @@ public sealed partial class Provider : FeatureProvider private readonly object _initLock = new object(); private bool _initializeCalled = false; + private readonly bool _waitIndefinitely; // There is no support for void task completion, so we use bool as a dummy result type. private readonly TaskCompletionSource _initCompletion = new TaskCompletionSource(); @@ -41,9 +42,17 @@ public sealed partial class Provider : FeatureProvider private const string ProviderShutdownMessage = "the provider has encountered a permanent error or been shutdown"; - internal Provider(ILdClient client) + private const string ProviderNotReadyMessage = + "the LaunchDarkly client did not become ready within the start wait time"; + + internal Provider(ILdClient client) : this(client, true) + { + } + + internal Provider(ILdClient client, bool waitIndefinitely) { _client = client; + _waitIndefinitely = waitIndefinitely; _logger = _client.GetLogger().SubLogger(NameSpace); _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger); _contextConverter = new EvalContextConverter(_logger); @@ -53,7 +62,30 @@ internal Provider(ILdClient client) /// Construct a new instance of the provider with the given configuration. /// /// A client configuration object - public Provider(Configuration config) : this(new LdClient(WrapConfig(config))) + public Provider(Configuration config) : this(new LdClient(WrapConfig(config)), false) + { + } + + /// + /// Construct a new instance of the provider with the given configuration and start wait time. + /// + /// + /// + /// The start wait time replaces the one configured with StartWaitTime and bounds the whole of + /// initialization: the LaunchDarkly client waits for up to that long while it is constructed, and + /// then reports whether it became ready. + /// + /// + /// waits nowhere, so initialization fails unless the client is already ready and + /// the application learns when it becomes usable from provider events. A null start wait time waits + /// indefinitely: nothing is waited for during construction, and initialization does not complete until the + /// data source becomes valid or fails permanently. + /// + /// + /// A client configuration object + /// How long to wait for the client to become ready, or null to wait indefinitely + public Provider(Configuration config, TimeSpan? startWait) + : this(new LdClient(WrapConfig(config, startWait ?? TimeSpan.Zero)), startWait == null) { } @@ -61,7 +93,7 @@ public Provider(Configuration config) : this(new LdClient(WrapConfig(config))) /// Construct a new instance of the provider with the given SDK key. /// /// The SDK key - public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build()))) + public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build())), false) { } @@ -92,6 +124,11 @@ private static Configuration WrapConfig(Configuration config) .Build(); } + private static Configuration WrapConfig(Configuration config, TimeSpan startWait) + { + return Configuration.Builder(WrapConfig(config)).StartWaitTime(startWait).Build(); + } + #region FeatureProvider Implementation /// @@ -159,6 +196,11 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); } + if (!_waitIndefinitely) + { + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderNotReadyMessage)); + } + return _initCompletion.Task; } diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index ec642dd..90d9ff3 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using System.Timers; @@ -83,6 +84,62 @@ public async Task ItHandlesValidInitializationWhenClientIsReadyAfterADelay() await provider.InitializeAsync(EvaluationContext.Empty); } + [Fact(Timeout = 5000)] + public async Task ItDoesNotWaitAgainWithAStartWaitTime() + { + var provider = new Provider(MakeNotReadyClient().Object, false); + + var exception = + await Record.ExceptionAsync(async () => await provider.InitializeAsync(EvaluationContext.Empty)); + Assert.NotNull(exception); + Assert.Equal("the LaunchDarkly client did not become ready within the start wait time", + exception.Message); + } + + [Fact(Timeout = 5000)] + public async Task ItWaitsIndefinitelyWithANullStartWaitTime() + { + var mockDataSourceStatus = new Mock(); + var provider = new Provider(MakeNotReadyClient(mockDataSourceStatus).Object, true); + var initialization = provider.InitializeAsync(EvaluationContext.Empty); + + Assert.False(initialization.IsCompleted); + + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Valid}); + + await initialization; + } + + [Fact(Timeout = 5000)] + public void ItAppliesAStartWaitTimeToTheClientConfiguration() + { + var provider = new Provider(Configuration.Builder("") + .DataSource(Components.ExternalUpdatesOnly) + .Events(Components.NoEvents) + .Build(), TimeSpan.FromMilliseconds(50)); + + Assert.NotNull(provider.GetClient()); + } + + private static Mock MakeNotReadyClient(Mock mockDataSourceStatus = null) + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + mockDataSourceStatus = mockDataSourceStatus ?? new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + mockClient.Setup(l => l.FlagTracker).Returns(new Mock().Object); + + return mockClient; + } + [Fact(Timeout = 5000)] public async Task ItCanBeShutdown() { From 874ba7b9c76b0a71457a7168e20a0107fc48a514 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:46:45 +0000 Subject: [PATCH 2/3] fix: Report status changes after initialization has failed Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- .../Provider.StatusProvider.cs | 14 +++++++++----- .../Provider.cs | 3 ++- .../ProviderTests.cs | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs index d2c474d..c4f4023 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Channels; using System.Threading.Tasks; using LaunchDarkly.Logging; @@ -11,17 +12,19 @@ public sealed partial class Provider private sealed class StatusProvider { private ProviderStatus _providerStatus = ProviderStatus.NotReady; - private bool _firstEvent = true; private readonly object _statusLock = new object(); private readonly Channel _eventChannel; private readonly string _providerName; private readonly Logger _logger; + private readonly Func _initializationCompleted; - public StatusProvider(Channel eventChannel, string providerName, Logger logger) + public StatusProvider(Channel eventChannel, string providerName, Logger logger, + Func initializationCompleted) { _eventChannel = eventChannel; _providerName = providerName; _logger = logger; + _initializationCompleted = initializationCompleted; } private void EmitProviderEvent(ProviderEventTypes type, string message) @@ -64,10 +67,11 @@ public void SetStatus(ProviderStatus status, string message = null) _providerStatus = status; // The OpenFeature client will emit a ready or error event when initialization completes. - // We want to avoid duplicating that event. - if (_firstEvent) + // The status change which completes initialization is therefore not emitted here, to avoid + // duplicating that event. Status changes after initialization has completed, including after it + // has failed, are emitted. + if (!_initializationCompleted()) { - _firstEvent = false; return; } switch (status) diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 0ddf9c7..eebd484 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -54,7 +54,8 @@ internal Provider(ILdClient client, bool waitIndefinitely) _client = client; _waitIndefinitely = waitIndefinitely; _logger = _client.GetLogger().SubLogger(NameSpace); - _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger); + _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger, + () => _initCompletion.Task.IsCompleted); _contextConverter = new EvalContextConverter(_logger); } diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index 90d9ff3..c620598 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -7,6 +7,7 @@ using LaunchDarkly.Sdk.Server; using LaunchDarkly.Sdk.Server.Interfaces; using Moq; +using OpenFeature.Constant; using OpenFeature.Model; using Xunit; using LaunchDarkly.Sdk.Server.Integrations; @@ -112,6 +113,22 @@ public async Task ItWaitsIndefinitelyWithANullStartWaitTime() await initialization; } + [Fact(Timeout = 5000)] + public async Task ItReportsBecomingReadyAfterInitializationFailed() + { + var mockDataSourceStatus = new Mock(); + var provider = new Provider(MakeNotReadyClient(mockDataSourceStatus).Object, false); + + await Record.ExceptionAsync(async () => await provider.InitializeAsync(EvaluationContext.Empty)); + + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Valid}); + + var payload = await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderReady, payload?.Type); + } + [Fact(Timeout = 5000)] public void ItAppliesAStartWaitTimeToTheClientConfiguration() { From d6ceb051ee7c6f02d4eb777f68624546456f2995 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:50:21 +0000 Subject: [PATCH 3/3] fix: Do not duplicate the error event for a failed initialization Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index eebd484..2a762f2 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -197,8 +197,11 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); } - if (!_waitIndefinitely) + if (!_waitIndefinitely && !_initCompletion.Task.IsCompleted) { + // The OpenFeature client emits an error event when initialization fails, so the status is recorded + // here, before initialization completes, without emitting an event of our own. + _statusProvider.SetStatus(ProviderStatus.Error, ProviderNotReadyMessage); _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderNotReadyMessage)); }