diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 971d3c7..5257232 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -153,10 +153,12 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke _initCompletion.TrySetResult(true); } - if (_client.DataSourceStatusProvider.Status.State == DataSourceState.Off) + var dataSourceStatus = _client.DataSourceStatusProvider.Status; + if (dataSourceStatus.State == DataSourceState.Off) { - _statusProvider.SetStatus(ProviderStatus.Error, ProviderShutdownMessage); - _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); + var message = DescribeLastError(dataSourceStatus, ProviderShutdownMessage); + _statusProvider.SetStatus(ProviderStatus.Error, message); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); } return _initCompletion.Task; @@ -210,18 +212,29 @@ private void StatusChangeHandler(object sender, DataSourceStatus status) // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate // flags. _statusProvider.SetStatus(ProviderStatus.Stale, - status.LastError?.Message ?? "encountered an unknown error"); + 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. - _statusProvider.SetStatus(ProviderStatus.Error, ProviderShutdownMessage); - _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); + 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) { diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index ec642dd..8df398c 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -6,6 +6,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; @@ -273,6 +274,78 @@ public async Task ItEmitsConfigurationChangedEvents() Assert.NotEqual(eventPayloadA?.FlagsChanged[0], eventPayloadB?.FlagsChanged[0]); } + private static async Task EmitStatusAndReadEvent(DataSourceStatus status) + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + mockClient.Setup(l => l.Initialized).Returns(true); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Valid + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var mockFlagTracker = new Mock(); + mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object); + + var provider = new Provider(mockClient.Object); + await provider.InitializeAsync(EvaluationContext.Empty); + + mockDataSourceStatus.Raise(e => e.StatusChanged += null, mockDataSourceStatus.Object, status); + + return await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload; + } + + [Fact(Timeout = 5000)] + public async Task ItIncludesTheLastErrorMessageInErrorEvents() + { + var errorEvent = await EmitStatusAndReadEvent(new DataSourceStatus + { + State = DataSourceState.Off, + LastError = DataSourceStatus.ErrorInfo.FromException(new System.Exception("the key was invalid")) + }); + Assert.Equal(ProviderEventTypes.ProviderError, errorEvent?.Type); + Assert.Equal("the key was invalid", errorEvent?.Message); + } + + [Fact(Timeout = 5000)] + public async Task ItDescribesHttpErrorsWithoutAMessageInErrorEvents() + { + var lastError = DataSourceStatus.ErrorInfo.FromHttpError(401); + var errorEvent = await EmitStatusAndReadEvent(new DataSourceStatus + { + State = DataSourceState.Off, + LastError = lastError + }); + Assert.Equal(ProviderEventTypes.ProviderError, errorEvent?.Type); + Assert.Equal(lastError.ToString(), errorEvent?.Message); + Assert.Contains("401", errorEvent?.Message); + } + + [Fact(Timeout = 5000)] + public async Task ItUsesADefaultMessageInErrorEventsWithoutALastError() + { + var errorEvent = await EmitStatusAndReadEvent(new DataSourceStatus { State = DataSourceState.Off }); + Assert.Equal(ProviderEventTypes.ProviderError, errorEvent?.Type); + Assert.Equal("the provider has encountered a permanent error or been shutdown", errorEvent?.Message); + } + + [Fact(Timeout = 5000)] + public async Task ItDescribesHttpErrorsWithoutAMessageInStaleEvents() + { + var lastError = DataSourceStatus.ErrorInfo.FromHttpError(503); + var staleEvent = await EmitStatusAndReadEvent(new DataSourceStatus + { + State = DataSourceState.Interrupted, + LastError = lastError + }); + Assert.Equal(ProviderEventTypes.ProviderStale, staleEvent?.Type); + Assert.Equal(lastError.ToString(), staleEvent?.Message); + } + [Fact(Timeout = 5000)] public void ItTracksCustomEvents() {