diff --git a/README.md b/README.md index 50aef52..9b69822 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 shut down, and stops waiting if the cancellation token given to `SetProviderAsync` is canceled. It has no timeout of its own; `StartWaitTime` applies to the client constructor. Since LaunchDarkly .NET SDK 8.17.0 a rejected SDK key is retried rather than failing permanently, so without a cancellation token `InitializeAsync` can wait indefinitely. Provider gap: [#71](https://github.com/launchdarkly/openfeature-dotnet-server/pull/71). | +| ✅ | 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. `Timeout.InfiniteTimeSpan` waits indefinitely, until the cancellation token given to `SetProviderAsync` is canceled. Since LaunchDarkly .NET SDK 8.17.0 a rejected SDK key is retried rather than failing permanently, so `Timeout.InfiniteTimeSpan` keeps waiting while the key is rejected. | | ✅ | 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 10 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 10 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 `Timeout.InfiniteTimeSpan`. Construction does not block, and `SetProviderAsync` completes once the data source becomes valid or fails permanently. Since LaunchDarkly .NET SDK 8.17.0 a rejected SDK key is retried rather than failing permanently, so pass a cancellation token to `SetProviderAsync` to bound the wait. + +```csharp +var provider = new Provider(config, Timeout.InfiniteTimeSpan); ``` #### Provider Shutdown diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs index faa2a69..273a978 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; @@ -12,17 +13,20 @@ private sealed class StatusProvider { private ProviderStatus _providerStatus = ProviderStatus.NotReady; private string _statusMessage; - private bool _firstEvent = true; + private bool _startupWaitCanceled; 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) @@ -68,7 +72,7 @@ public void StartupWaitCanceled() { lock (_statusLock) { - _firstEvent = false; + _startupWaitCanceled = true; if (_providerStatus != ProviderStatus.NotReady) { EmitStatusEvent(_providerStatus, _statusMessage); @@ -108,10 +112,11 @@ public void SetStatus(ProviderStatus status, string message = null) _providerStatus = status; _statusMessage = message; // 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, or after the startup wait was canceled, are emitted. + if (!_startupWaitCanceled && !_initializationCompleted()) { - _firstEvent = false; return; } diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index f241b47..f3d47d3 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -1,278 +1,341 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using LaunchDarkly.Logging; -using LaunchDarkly.Sdk; -using LaunchDarkly.Sdk.Server; -using LaunchDarkly.Sdk.Server.Interfaces; -using OpenFeature; -using OpenFeature.Constant; -using OpenFeature.Model; - -namespace LaunchDarkly.OpenFeature.ServerProvider -{ - /// - /// An OpenFeature which enables the use of the LaunchDarkly Server-Side SDK for .NET - /// with OpenFeature. - /// - /// - /// var provider = new Provider(Configuration.Builder("my-sdk-key").Build()); - /// - /// OpenFeature.Api.Instance.SetProvider(provider); - /// - /// var client = OpenFeature.Api.Instance.GetClient(); - /// - public sealed partial class Provider : FeatureProvider - { - private const string NameSpace = "OpenFeature.ServerProvider"; - private readonly Metadata _metadata = new Metadata($"LaunchDarkly.{NameSpace}"); - private readonly ILdClient _client; - private readonly EvalContextConverter _contextConverter; - private readonly StatusProvider _statusProvider; - - private readonly object _initLock = new object(); - private bool _initializeCalled = false; - - // There is no support for void task completion, so we use bool as a dummy result type. - private readonly TaskCompletionSource _initCompletion = new TaskCompletionSource(); - private readonly Logger _logger; - - private const string ProviderShutdownMessage = - "the provider has encountered a permanent error or been shutdown"; - - internal Provider(ILdClient client) - { - _client = client; - _logger = _client.GetLogger().SubLogger(NameSpace); - _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger); - _contextConverter = new EvalContextConverter(_logger); - } - - /// - /// Construct a new instance of the provider with the given configuration. - /// - /// A client configuration object - 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()))) - { - } - - /// - /// - /// Get the underlying client instance. - /// - /// - /// If the initialization/shutdown features of OpenFeature are being used, then the returned client instance - /// may be shutdown if the provider has been removed from the OpenFeature API instance. - /// - /// - /// This client instance can be used to take advantage of features which are not part of OpenFeature. - /// For instance using migration flags. - /// - /// - /// The LaunchDarkly client instance - public ILdClient GetClient() - { - return _client; - } - - private static Configuration WrapConfig(Configuration config) - { - return Configuration.Builder(config) - .WrapperInfo(Components.WrapperInfo().Name("open-feature-dotnet-server") - .Version(typeof(Provider).Assembly.GetName().Version.ToString())) - .Build(); - } - - #region FeatureProvider Implementation - - /// - public override Metadata GetMetadata() => _metadata; - - /// - public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, - EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client - .BoolVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue) - .ToResolutionDetails(flagKey)); - - /// - public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, - EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client - .StringVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue) - .ToResolutionDetails(flagKey)); - - /// - public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, - EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client - .IntVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue) - .ToResolutionDetails(flagKey)); - - /// - public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, - EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client - .DoubleVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue) - .ToResolutionDetails(flagKey)); - - /// - public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, - EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client - .JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null) - .ToValueDetail(defaultValue).ToResolutionDetails(flagKey)); - - /// - public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) - { - lock (_initLock) - { - if (_initializeCalled) - { - return WaitForInitializationAsync(cancellationToken); - } - _initializeCalled = true; - } - - _client.FlagTracker.FlagChanged += FlagChangeHandler; - - _client.DataSourceStatusProvider.StatusChanged += StatusChangeHandler; - - // We start listening for status changes, and then we check the current status change. If we do not check - // then we could have missed a status change. If we check before registering a listener, then we could - // miss a change between checking and listening. Doing it this way we can get duplicates, but we filter - // when the status does not actually change, so we won't emit duplicate events. - if (_client.Initialized) - { - _statusProvider.SetStatus(ProviderStatus.Ready); - _initCompletion.TrySetResult(true); - } - - var dataSourceStatus = _client.DataSourceStatusProvider.Status; - if (dataSourceStatus.State == DataSourceState.Off) - { - var message = DescribeLastError(dataSourceStatus, ProviderShutdownMessage); - _statusProvider.SetStatus(ProviderStatus.Error, message); - _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); - } - - return WaitForInitializationAsync(cancellationToken); - } - - private async Task WaitForInitializationAsync(CancellationToken cancellationToken) - { - if (!cancellationToken.CanBeCanceled) - { - await _initCompletion.Task.ConfigureAwait(false); - return; - } - - var cancellation = new TaskCompletionSource(); - using (cancellationToken.Register(() => cancellation.TrySetCanceled(cancellationToken))) - { - var completed = await Task.WhenAny(_initCompletion.Task, cancellation.Task).ConfigureAwait(false); - if (completed != _initCompletion.Task) - { - _statusProvider.StartupWaitCanceled(); - } - - await completed.ConfigureAwait(false); - } - } - - /// - public override Task ShutdownAsync(CancellationToken cancellationToken = default) - { - _client.DataSourceStatusProvider.StatusChanged -= StatusChangeHandler; - _client.FlagTracker.FlagChanged -= FlagChangeHandler; - (_client as IDisposable)?.Dispose(); - _statusProvider.SetStatus(ProviderStatus.NotReady); - return Task.CompletedTask; - } - - #endregion - - private void FlagChangeHandler(object sender, FlagChangeEvent changeEvent) - { - Task.Run(() => SafeWriteChangeEvent(changeEvent)).ConfigureAwait(false); - } - - private async Task SafeWriteChangeEvent(FlagChangeEvent changeEvent) - { - try - { - await EventChannel.Writer.WriteAsync(new ProviderEventPayload - { - ProviderName = _metadata.Name, - Type = ProviderEventTypes.ProviderConfigurationChanged, - FlagsChanged = new List { changeEvent.Key }, - }).ConfigureAwait(false); - } - catch (Exception e) - { - _logger.Warn($"Encountered an error sending configuration changed events: {e.Message}"); - } - } - - private void StatusChangeHandler(object sender, DataSourceStatus status) - { - switch (status.State) - { - case DataSourceState.Initializing: - break; - case DataSourceState.Valid: - _statusProvider.SetStatus(ProviderStatus.Ready); - _initCompletion.TrySetResult(true); - break; - case DataSourceState.Interrupted: - // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate - // flags. - _statusProvider.SetStatus(ProviderStatus.Stale, - DescribeLastError(status, "encountered an unknown error")); - break; - case DataSourceState.Off: - default: - // The status is an error, and not fatal, because the LaunchDarkly client can continue to - // evaluate flags using the data it already has. - var message = DescribeLastError(status, ProviderShutdownMessage); - _statusProvider.SetStatus(ProviderStatus.Error, message); - _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); - break; - } - } - - private static string DescribeLastError(DataSourceStatus status, string fallback) - { - if (status.LastError is DataSourceStatus.ErrorInfo lastError) - { - return lastError.Message ?? lastError.ToString(); - } - - return fallback; - } - - /// - public override void Track(string trackingEventName, EvaluationContext evaluationContext = null, TrackingEventDetails trackingEventDetails = default) - { - var (value, details) = trackingEventDetails.ToLdValue(); - - if (value.HasValue) - { - _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details, value.Value); - } - else if (details.Type != LdValueType.Null) - { - _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details); - } - else - { - _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext)); - } - } - } -} +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Server; +using LaunchDarkly.Sdk.Server.Interfaces; +using OpenFeature; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace LaunchDarkly.OpenFeature.ServerProvider +{ + /// + /// An OpenFeature which enables the use of the LaunchDarkly Server-Side SDK for .NET + /// with OpenFeature. + /// + /// + /// var provider = new Provider(Configuration.Builder("my-sdk-key").Build()); + /// + /// OpenFeature.Api.Instance.SetProvider(provider); + /// + /// var client = OpenFeature.Api.Instance.GetClient(); + /// + public sealed partial class Provider : FeatureProvider + { + private const string NameSpace = "OpenFeature.ServerProvider"; + private readonly Metadata _metadata = new Metadata($"LaunchDarkly.{NameSpace}"); + private readonly ILdClient _client; + private readonly EvalContextConverter _contextConverter; + private readonly StatusProvider _statusProvider; + + private readonly object _initLock = new object(); + // Guards recording a status together with the matching initialization outcome, so a concurrent data source + // status change cannot be interleaved between the two. + private readonly object _completionLock = 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(); + private readonly Logger _logger; + + private const string ProviderShutdownMessage = + "the provider has encountered a permanent error or been shutdown"; + + 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, + () => _initCompletion.Task.IsCompleted); + _contextConverter = new EvalContextConverter(_logger); + } + + /// + /// Construct a new instance of the provider with the given configuration. + /// + /// A client configuration object + 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. + /// 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 + /// to wait indefinitely + public Provider(Configuration config, TimeSpan startWait) + : this(new LdClient(WrapConfig(config, + startWait == Timeout.InfiniteTimeSpan ? TimeSpan.Zero : startWait)), + startWait == Timeout.InfiniteTimeSpan) + { + } + + /// + /// 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())), false) + { + } + + /// + /// + /// Get the underlying client instance. + /// + /// + /// If the initialization/shutdown features of OpenFeature are being used, then the returned client instance + /// may be shutdown if the provider has been removed from the OpenFeature API instance. + /// + /// + /// This client instance can be used to take advantage of features which are not part of OpenFeature. + /// For instance using migration flags. + /// + /// + /// The LaunchDarkly client instance + public ILdClient GetClient() + { + return _client; + } + + private static Configuration WrapConfig(Configuration config) + { + return Configuration.Builder(config) + .WrapperInfo(Components.WrapperInfo().Name("open-feature-dotnet-server") + .Version(typeof(Provider).Assembly.GetName().Version.ToString())) + .Build(); + } + + private static Configuration WrapConfig(Configuration config, TimeSpan startWait) + { + return Configuration.Builder(WrapConfig(config)).StartWaitTime(startWait).Build(); + } + + #region FeatureProvider Implementation + + /// + public override Metadata GetMetadata() => _metadata; + + /// + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, + EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client + .BoolVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue) + .ToResolutionDetails(flagKey)); + + /// + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, + EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client + .StringVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue) + .ToResolutionDetails(flagKey)); + + /// + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, + EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client + .IntVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue) + .ToResolutionDetails(flagKey)); + + /// + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client + .DoubleVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue) + .ToResolutionDetails(flagKey)); + + /// + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, + EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client + .JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null) + .ToValueDetail(defaultValue).ToResolutionDetails(flagKey)); + + /// + public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + lock (_initLock) + { + if (_initializeCalled) + { + return WaitForInitializationAsync(cancellationToken); + } + _initializeCalled = true; + } + + _client.FlagTracker.FlagChanged += FlagChangeHandler; + + _client.DataSourceStatusProvider.StatusChanged += StatusChangeHandler; + + // We start listening for status changes, and then we check the current status change. If we do not check + // then we could have missed a status change. If we check before registering a listener, then we could + // miss a change between checking and listening. Doing it this way we can get duplicates, but we filter + // when the status does not actually change, so we won't emit duplicate events. + lock (_completionLock) + { + if (_client.Initialized) + { + _statusProvider.SetStatus(ProviderStatus.Ready); + _initCompletion.TrySetResult(true); + } + + var dataSourceStatus = _client.DataSourceStatusProvider.Status; + if (dataSourceStatus.State == DataSourceState.Off) + { + var message = DescribeLastError(dataSourceStatus, ProviderShutdownMessage); + _statusProvider.SetStatus(ProviderStatus.Error, message); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); + } + + 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)); + } + } + + return WaitForInitializationAsync(cancellationToken); + } + + private async Task WaitForInitializationAsync(CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + await _initCompletion.Task.ConfigureAwait(false); + return; + } + + var cancellation = new TaskCompletionSource(); + using (cancellationToken.Register(() => cancellation.TrySetCanceled(cancellationToken))) + { + var completed = await Task.WhenAny(_initCompletion.Task, cancellation.Task).ConfigureAwait(false); + if (completed != _initCompletion.Task) + { + _statusProvider.StartupWaitCanceled(); + } + + await completed.ConfigureAwait(false); + } + } + + /// + public override Task ShutdownAsync(CancellationToken cancellationToken = default) + { + _client.DataSourceStatusProvider.StatusChanged -= StatusChangeHandler; + _client.FlagTracker.FlagChanged -= FlagChangeHandler; + (_client as IDisposable)?.Dispose(); + _statusProvider.SetStatus(ProviderStatus.NotReady); + return Task.CompletedTask; + } + + #endregion + + private void FlagChangeHandler(object sender, FlagChangeEvent changeEvent) + { + Task.Run(() => SafeWriteChangeEvent(changeEvent)).ConfigureAwait(false); + } + + private async Task SafeWriteChangeEvent(FlagChangeEvent changeEvent) + { + try + { + await EventChannel.Writer.WriteAsync(new ProviderEventPayload + { + ProviderName = _metadata.Name, + Type = ProviderEventTypes.ProviderConfigurationChanged, + FlagsChanged = new List { changeEvent.Key }, + }).ConfigureAwait(false); + } + catch (Exception e) + { + _logger.Warn($"Encountered an error sending configuration changed events: {e.Message}"); + } + } + + private void StatusChangeHandler(object sender, DataSourceStatus status) + { + lock (_completionLock) + { + HandleStatusChange(status); + } + } + + private void HandleStatusChange(DataSourceStatus status) + { + switch (status.State) + { + case DataSourceState.Initializing: + break; + case DataSourceState.Valid: + _statusProvider.SetStatus(ProviderStatus.Ready); + _initCompletion.TrySetResult(true); + break; + case DataSourceState.Interrupted: + // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate + // flags. + _statusProvider.SetStatus(ProviderStatus.Stale, + DescribeLastError(status, "encountered an unknown error")); + break; + case DataSourceState.Off: + default: + // The status is an error, and not fatal, because the LaunchDarkly client can continue to + // evaluate flags using the data it already has. + var message = DescribeLastError(status, ProviderShutdownMessage); + _statusProvider.SetStatus(ProviderStatus.Error, message); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); + break; + } + } + + private static string DescribeLastError(DataSourceStatus status, string fallback) + { + if (status.LastError is DataSourceStatus.ErrorInfo lastError) + { + return lastError.Message ?? lastError.ToString(); + } + + return fallback; + } + + /// + public override void Track(string trackingEventName, EvaluationContext evaluationContext = null, TrackingEventDetails trackingEventDetails = default) + { + var (value, details) = trackingEventDetails.ToLdValue(); + + if (value.HasValue) + { + _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details, value.Value); + } + else if (details.Type != LdValueType.Null) + { + _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details); + } + else + { + _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext)); + } + } + } +} diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index 9c6438b..ae65c11 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -85,6 +85,78 @@ 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 ItWaitsIndefinitelyWithAnInfiniteStartWaitTime() + { + 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 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() + { + 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 ItStopsWaitingForInitializationWhenTheTokenIsCanceled() {