diff --git a/pkgs/sdk/server/contract-tests/SdkClientEntity.cs b/pkgs/sdk/server/contract-tests/SdkClientEntity.cs index f0d62a8e..5c05d842 100644 --- a/pkgs/sdk/server/contract-tests/SdkClientEntity.cs +++ b/pkgs/sdk/server/contract-tests/SdkClientEntity.cs @@ -514,6 +514,45 @@ private static Configuration BuildSdkConfig(SdkConfigParams sdkParams, ILogAdapt if (synchronizers.Count > 0) { dataSystemBuilder.Synchronizers(synchronizers.ToArray()); + + // Find the best synchronizer to use for FDv1 fallback configuration + // Prefer polling synchronizers since FDv1 fallback is polling-based + SdkConfigSynchronizerParams synchronizerForFallback = null; + + // First, try to find a polling synchronizer (check secondary first, then primary) + if (sdkParams.DataSystem.Synchronizers.Secondary != null && + sdkParams.DataSystem.Synchronizers.Secondary.Polling != null) + { + synchronizerForFallback = sdkParams.DataSystem.Synchronizers.Secondary; + } + else if (sdkParams.DataSystem.Synchronizers.Primary != null && + sdkParams.DataSystem.Synchronizers.Primary.Polling != null) + { + synchronizerForFallback = sdkParams.DataSystem.Synchronizers.Primary; + } + // If no polling synchronizer found, use primary synchronizer (could be streaming) + else if (sdkParams.DataSystem.Synchronizers.Primary != null) + { + synchronizerForFallback = sdkParams.DataSystem.Synchronizers.Primary; + } + + if (synchronizerForFallback != null) + { + // Only configure global polling endpoints if we have a polling synchronizer with a custom base URI + // This ensures the FDv1 fallback synchronizer uses the same base URI without overwriting + // existing polling endpoint configuration + if (synchronizerForFallback.Polling != null && + synchronizerForFallback.Polling.BaseUri != null) + { + endpoints.Polling(synchronizerForFallback.Polling.BaseUri); + } + + var fdv1Fallback = CreateFDv1FallbackSynchronizer(synchronizerForFallback); + if (fdv1Fallback != null) + { + dataSystemBuilder.FDv1FallbackSynchronizer(fdv1Fallback); + } + } } } @@ -568,6 +607,33 @@ private static IComponentConfigurer CreateSynchronizer( return null; } + private static IComponentConfigurer CreateFDv1FallbackSynchronizer( + SdkConfigSynchronizerParams synchronizer) + { + // FDv1 fallback synchronizer is always polling-based + var fdv1PollingBuilder = DataSystemComponents.FDv1Polling(); + + // Configure polling interval if the synchronizer has polling configuration + if (synchronizer.Polling != null) + { + if (synchronizer.Polling.PollIntervalMs.HasValue) + { + fdv1PollingBuilder.PollInterval(TimeSpan.FromMilliseconds(synchronizer.Polling.PollIntervalMs.Value)); + } + // Note: FDv1 polling doesn't support ServiceEndpointsOverride, so base URI + // will use the global service endpoints configuration + } + else if (synchronizer.Streaming != null) + { + // For streaming synchronizers, we still create a polling fallback + // Use default polling interval since streaming doesn't have a poll interval + // Note: FDv1 polling doesn't support ServiceEndpointsOverride, so base URI + // will use the global service endpoints configuration + } + + return fdv1PollingBuilder; + } + private MigrationVariationResponse DoMigrationVariation(MigrationVariationParams migrationVariation) { var defaultStage = MigrationStageExtensions.FromDataModelString(migrationVariation.DefaultStage); diff --git a/pkgs/sdk/server/contract-tests/test-supressions-fdv2.txt b/pkgs/sdk/server/contract-tests/test-supressions-fdv2.txt index f9b931fb..b35086e0 100644 --- a/pkgs/sdk/server/contract-tests/test-supressions-fdv2.txt +++ b/pkgs/sdk/server/contract-tests/test-supressions-fdv2.txt @@ -5,6 +5,5 @@ streaming/retry behavior/do not retry after unrecoverable HTTP error on reconnec streaming/retry behavior/do not retry after unrecoverable HTTP error on reconnect/error 403 streaming/retry behavior/do not retry after unrecoverable HTTP error on reconnect/error 405 streaming/fdv2/reconnection state management/initializes from 2 polling initializers -streaming/fdv2/fallback to FDv1 handling streaming/fdv2/disconnects on goodbye streaming/fdv2/reconnection state management/initializes from polling initializer diff --git a/pkgs/sdk/server/src/Interfaces/DataSourceStatus.cs b/pkgs/sdk/server/src/Interfaces/DataSourceStatus.cs index 589fb320..affd59ae 100644 --- a/pkgs/sdk/server/src/Interfaces/DataSourceStatus.cs +++ b/pkgs/sdk/server/src/Interfaces/DataSourceStatus.cs @@ -80,6 +80,12 @@ public struct ErrorInfo /// public DateTime Time { get; set; } + /// + /// The error indicates to fall back to FDv1. (At the time of writing this, this was indicated + /// via the x-ld-fd-fallback header, but this may change in the future. This is just info for posterity.) + /// + public bool FDv1Fallback { get; set; } + /// /// Constructs an instance based on an exception. /// diff --git a/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2DataSource.cs b/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2DataSource.cs index 48bdda7b..bd2577b5 100644 --- a/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2DataSource.cs +++ b/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2DataSource.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using LaunchDarkly.Sdk.Server.Interfaces; using LaunchDarkly.Sdk.Server.Subsystems; + using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; namespace LaunchDarkly.Sdk.Server.Internal.DataSources @@ -16,7 +17,7 @@ internal static class FDv2DataSource /// the sink that receives updates from the active source /// List of data source factories used for initialization /// List of data source factories used for synchronization - /// List of data source factories used for FDv1 synchronization + /// List of data source factories used for FDv1 synchronization if fallback to FDv1 occurs /// a new data source instance public static IDataSource CreateFDv2DataSource( IDataSourceUpdatesV2 updatesSink, @@ -31,7 +32,7 @@ public static IDataSource CreateFDv2DataSource( ActionApplierFactory fastFallbackApplierFactory = (actionable) => new ActionApplierFastFallback(actionable); ActionApplierFactory timedFallbackAndRecoveryApplierFactory = (actionable) => new ActionApplierTimedFallbackAndRecovery(actionable); - + ActionApplierFactory fdv1FallbackApplierFactory = (actionable) => new FDv1FallbackActionApplier(actionable); var underlyingComposites = new List<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory)>(); // Only create the initializers composite if initializers are provided @@ -71,13 +72,32 @@ public static IDataSource CreateFDv2DataSource( return new CompositeSource(sink as IDataSourceUpdatesV2, synchronizersFactoryTuples); }, - null // TODO: add fallback to FDv1 logic, null for the moment as once we're on the synchronizers, we stay there + // Only attach FDv1 fallback applier if FDv1 synchronizers are actually provided + (fdv1Synchronizers != null && fdv1Synchronizers.Count > 0) ? fdv1FallbackApplierFactory : null )); } - var combinedCompositeSource = new CompositeSource(updatesSink, underlyingComposites, circular: false); + // Add the FDv1 fallback synchronizers composite if provided + if (fdv1Synchronizers != null && fdv1Synchronizers.Count > 0) + { + underlyingComposites.Add(( + // Create fdv1SynchronizersCompositeSource with action logic unique to fdv1Synchronizers + (sink) => + { + var fdv1SynchronizersFactoryTuples = + new List<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory)>(); + for (int i = 0; i < fdv1Synchronizers.Count; i++) + { + fdv1SynchronizersFactoryTuples.Add((fdv1Synchronizers[i], timedFallbackAndRecoveryApplierFactory)); // fdv1 synchronizers behave same as synchronizers + } + + return new CompositeSource(sink as IDataSourceUpdatesV2, fdv1SynchronizersFactoryTuples); + }, + null // no action applier for fdv1Synchronizers as a whole + )); + } - // TODO: add fallback to FDv1 logic + var combinedCompositeSource = new CompositeSource(updatesSink, underlyingComposites, circular: false); return combinedCompositeSource; } @@ -265,5 +285,31 @@ public void Apply(ChangeSet changeSet) _actionable.StartCurrent(); } } + + private class FDv1FallbackActionApplier : IDataSourceObserver + { + private readonly ICompositeSourceActionable _actionable; + + public FDv1FallbackActionApplier(ICompositeSourceActionable actionable) + { + _actionable = actionable ?? throw new ArgumentNullException(nameof(actionable)); + } + + public void UpdateStatus(DataSourceState newState, DataSourceStatus.ErrorInfo? newError) + { + if (newError != null && newError.Value.FDv1Fallback) + { + _actionable.BlacklistCurrent(); // blacklist the synchronizers altogether + _actionable.DisposeCurrent(); // dispose the synchronizers + _actionable.GoToNext(); // go to the FDv1 fallback synchronizer + _actionable.StartCurrent(); // start the FDv1 fallback synchronizer + } + } + + public void Apply(ChangeSet changeSet) + { + // this FDv1 fallback action applier doesn't care about apply, it only looks for the FDv1Fallback flag in the errors + } + } } } diff --git a/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingDataSource.cs b/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingDataSource.cs index 06b36a80..2a469a7a 100644 --- a/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingDataSource.cs +++ b/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingDataSource.cs @@ -96,6 +96,15 @@ private async Task UpdateTaskAsync() catch (UnsuccessfulResponseException ex) { var errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(ex.StatusCode); + + // Check for LD fallback header + if (ex.Headers != null) + { + errorInfo.FDv1Fallback = ex.Headers + .Where(h => string.Equals(h.Key, "x-ld-fd-fallback", StringComparison.OrdinalIgnoreCase)) + .SelectMany(h => h.Value) + .Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); + } if (HttpErrors.IsRecoverable(ex.StatusCode)) { diff --git a/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingRequestor.cs b/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingRequestor.cs index 1f61d52a..d690414b 100644 --- a/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingRequestor.cs +++ b/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingRequestor.cs @@ -105,7 +105,7 @@ internal FDv2PollingRequestor(LdClientContext context, Uri baseUri) if (!response.IsSuccessStatusCode) { - throw new UnsuccessfulResponseException((int)response.StatusCode); + throw new UnsuccessfulResponseException((int)response.StatusCode, response.Headers); } lock (_etags) diff --git a/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2StreamingDataSource.cs b/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2StreamingDataSource.cs index 0a6b4a5d..eba876f2 100644 --- a/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2StreamingDataSource.cs +++ b/pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2StreamingDataSource.cs @@ -275,6 +275,16 @@ private void OnError(object sender, ExceptionEventArgs e) { var status = respEx.StatusCode; errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(status); + + // Check for LD fallback header + if (respEx.Headers != null) + { + errorInfo.FDv1Fallback = respEx.Headers + .Where(h => string.Equals(h.Key, "x-ld-fd-fallback", StringComparison.OrdinalIgnoreCase)) + .SelectMany(h => h.Value) + .Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); + } + RecordStreamInit(true); if (!HttpErrors.IsRecoverable(status)) { diff --git a/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj b/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj index 9f0a869e..67102b1f 100644 --- a/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj +++ b/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj @@ -40,8 +40,8 @@ - - + + diff --git a/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2DataSourceTest.cs b/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2DataSourceTest.cs index 25c74a81..68878aab 100644 --- a/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2DataSourceTest.cs +++ b/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2DataSourceTest.cs @@ -1045,6 +1045,133 @@ public async Task CanDisposeWhenSynchronizersFallingBackUnthrottled() // The result may be true or false depending on implementation, but the key is that disposal works } + [Fact] + public async Task ErrorWithFDv1FallbackTriggersFallbackToFDv1Synchronizers() + { + // Create a capturing sink to observe all updates + var capturingSink = new CapturingDataSourceUpdatesWithHeaders(); + + // Track whether the synchronizer factory was invoked + bool synchronizerFactoryInvoked = false; + IDataSourceUpdatesV2 synchronizerUpdateSink = null; + + // Create synchronizer factory: emits Initializing, then reports Interrupted with FDv1Fallback error + SourceFactory synchronizerFactory = (updatesSink) => + { + synchronizerFactoryInvoked = true; + synchronizerUpdateSink = updatesSink; + var source = new MockDataSourceWithInit( + async () => + { + // Emit Initializing + updatesSink.UpdateStatus(DataSourceState.Initializing, null); + await Task.Delay(10); + + // Report Interrupted with error that has FDv1Fallback = true + var errorInfo = new DataSourceStatus.ErrorInfo + { + Kind = DataSourceStatus.ErrorKind.ErrorResponse, + StatusCode = 503, + FDv1Fallback = true, + Time = DateTime.Now + }; + updatesSink.UpdateStatus(DataSourceState.Interrupted, errorInfo); + await Task.Delay(10); + } + ); + return source; + }; + + // Track whether the fdv1Synchronizer factory was invoked + bool fdv1SynchronizerFactoryInvoked = false; + IDataSourceUpdatesV2 fdv1SynchronizerUpdateSink = null; + + // Create dummy data for fdv1Synchronizer + var fdv1SynchronizerDummyData = new FullDataSet(new Dictionary>()); + + // Create fdv1Synchronizer factory: emits Initializing, calls init with dummy data, then reports Valid + SourceFactory fdv1SynchronizerFactory = (updatesSink) => + { + fdv1SynchronizerFactoryInvoked = true; + fdv1SynchronizerUpdateSink = updatesSink; + var source = new MockDataSourceWithInit( + async () => + { + // Emit Initializing + updatesSink.UpdateStatus(DataSourceState.Initializing, null); + await Task.Delay(10); + + // Report Valid + updatesSink.UpdateStatus(DataSourceState.Valid, null); + await Task.Delay(10); + + // Call Apply with dummy data + updatesSink.Apply(new ChangeSet( + ChangeSetType.Full, + Selector.Empty, + fdv1SynchronizerDummyData.Data, + null + )); + await Task.Delay(10); + } + ); + return source; + }; + + // Create FDv2DataSource with no initializers, one synchronizer, and one fdv1Synchronizer + var initializers = new List(); + var synchronizers = new List { synchronizerFactory }; + var fdv1Synchronizers = new List { fdv1SynchronizerFactory }; + + var dataSource = FDv2DataSource.CreateFDv2DataSource( + capturingSink, + initializers, + synchronizers, + fdv1Synchronizers + ); + + // Start the data source + var startTask = dataSource.Start(); + + // Wait for status updates - we expect: + // 1. Initializing (from synchronizer) + // 2. Interrupted (from synchronizer with FDv1Fallback error) + // 3. Interrupted (initializing from fdv1Synchronizer is mapped to interrupted by sanitizer after fallback) + // 4. Valid (from fdv1Synchronizer) + var statusUpdates = capturingSink.WaitForStatusUpdates(4, TimeSpan.FromSeconds(5)); + + // Verify that Start() completed successfully + var startResult = await startTask; + Assert.True(startResult); + + // Verify status updates + Assert.True(statusUpdates.Count >= 4, $"Expected at least 4 status updates, got {statusUpdates.Count}"); + + // Position 0: Initializing (from synchronizer) + Assert.Equal(DataSourceState.Initializing, statusUpdates[0].State); + + // Position 1: Interrupted (from synchronizer with FDv1Fallback error) + Assert.Equal(DataSourceState.Interrupted, statusUpdates[1].State); + Assert.NotNull(statusUpdates[1].LastError); + Assert.True(statusUpdates[1].LastError.Value.FDv1Fallback, "FDv1Fallback should be true in the error"); + + // Position 2: Interrupted (initializing from fdv1Synchronizer is mapped to interrupted by sanitizer after fallback) + Assert.Equal(DataSourceState.Interrupted, statusUpdates[2].State); + + // Position 3: Valid (from fdv1Synchronizer) + Assert.Equal(DataSourceState.Valid, statusUpdates[3].State); + + // Verify that both factories were invoked + Assert.True(synchronizerFactoryInvoked, "Synchronizer factory should have been invoked"); + Assert.True(fdv1SynchronizerFactoryInvoked, "FDv1Synchronizer factory should have been invoked after fallback"); + + // Verify that Apply was called with fdv1Synchronizer dummy data + var changeSet = capturingSink.Applies.ExpectValue(TimeSpan.FromSeconds(1)); + Assert.Equal(ChangeSetType.Full, changeSet.Type); + + dataSource.Dispose(); + } + // Mock data source that executes an async action when started private class MockDataSourceWithInit : IDataSource diff --git a/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2PollingDataSourceTest.cs b/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2PollingDataSourceTest.cs index 8bfe601d..47643028 100644 --- a/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2PollingDataSourceTest.cs +++ b/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2PollingDataSourceTest.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Text.Json; using System.Threading.Tasks; using LaunchDarkly.Sdk.Internal.Http; @@ -818,5 +821,150 @@ public async Task JsonErrorInEventHasInvalidDataErrorKind() Assert.True(dataSource.Initialized); } } + + [Fact] + public void RecoverableHttpErrorWithFallbackHeaderSetsFDv1Fallback() + { + // Create an HttpResponseMessage with the fallback header + using (var response = new HttpResponseMessage((HttpStatusCode)503)) + { + response.Headers.Add("x-ld-fd-fallback", "true"); + var exception = new UnsuccessfulResponseException(503, response.Headers); + + _mockRequestor.Setup(r => r.PollingRequestAsync(It.IsAny())) + .ThrowsAsync(exception); + + using (var dataSource = MakeDataSource()) + { + _ = dataSource.Start(); + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Interrupted, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(503, status.LastError.Value.StatusCode); + Assert.True(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be true when fallback header is present"); + + Assert.False(dataSource.Initialized); + } + } + } + + [Fact] + public async Task UnrecoverableHttpErrorWithFallbackHeaderSetsFDv1Fallback() + { + // Create an HttpResponseMessage with the fallback header + using (var response = new HttpResponseMessage((HttpStatusCode)401)) + { + response.Headers.Add("x-ld-fd-fallback", "true"); + var exception = new UnsuccessfulResponseException(401, response.Headers); + + _mockRequestor.Setup(r => r.PollingRequestAsync(It.IsAny())) + .ThrowsAsync(exception); + + using (var dataSource = MakeDataSource()) + { + var startTask = dataSource.Start(); + + var result = await startTask; + Assert.True(result); // Init task completes even on error + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Off, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(401, status.LastError.Value.StatusCode); + Assert.True(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be true when fallback header is present"); + + Assert.False(dataSource.Initialized); + } + } + } + + [Fact] + public void RecoverableHttpErrorWithoutFallbackHeaderDoesNotSetFDv1Fallback() + { + // Create an HttpResponseMessage without the fallback header + using (var response = new HttpResponseMessage((HttpStatusCode)503)) + { + var exception = new UnsuccessfulResponseException(503, response.Headers); + + _mockRequestor.Setup(r => r.PollingRequestAsync(It.IsAny())) + .ThrowsAsync(exception); + + using (var dataSource = MakeDataSource()) + { + _ = dataSource.Start(); + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Interrupted, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(503, status.LastError.Value.StatusCode); + Assert.False(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be false when fallback header is not present"); + + Assert.False(dataSource.Initialized); + } + } + } + + [Fact] + public void RecoverableHttpErrorWithFallbackHeaderFalseDoesNotSetFDv1Fallback() + { + // Create an HttpResponseMessage with the fallback header set to false + using (var response = new HttpResponseMessage((HttpStatusCode)503)) + { + response.Headers.Add("x-ld-fd-fallback", "false"); + var exception = new UnsuccessfulResponseException(503, response.Headers); + + _mockRequestor.Setup(r => r.PollingRequestAsync(It.IsAny())) + .ThrowsAsync(exception); + + using (var dataSource = MakeDataSource()) + { + _ = dataSource.Start(); + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Interrupted, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(503, status.LastError.Value.StatusCode); + Assert.False(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be false when fallback header value is false"); + + Assert.False(dataSource.Initialized); + } + } + } + + [Fact] + public async Task UnrecoverableHttpErrorWithFallbackHeaderFalseDoesNotSetFDv1Fallback() + { + // Create an HttpResponseMessage with the fallback header set to false + using (var response = new HttpResponseMessage((HttpStatusCode)401)) + { + response.Headers.Add("x-ld-fd-fallback", "false"); + var exception = new UnsuccessfulResponseException(401, response.Headers); + + _mockRequestor.Setup(r => r.PollingRequestAsync(It.IsAny())) + .ThrowsAsync(exception); + + using (var dataSource = MakeDataSource()) + { + var startTask = dataSource.Start(); + + var result = await startTask; + Assert.True(result); // Init task completes even on error + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Off, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(401, status.LastError.Value.StatusCode); + Assert.False(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be false when fallback header value is false"); + + Assert.False(dataSource.Initialized); + } + } + } } } diff --git a/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2StreamingDataSourceTest.cs b/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2StreamingDataSourceTest.cs index 45a8154f..4d319ccf 100644 --- a/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2StreamingDataSourceTest.cs +++ b/pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2StreamingDataSourceTest.cs @@ -570,6 +570,31 @@ public void RecoverableHttpErrorUpdatesStatusAndLogsWarning() } } + [Fact] + public void RecoverableHttpErrorWithFallbackHeaderSetsFDv1Fallback() + { + using (var dataSource = MakeDataSource()) + { + dataSource.Start(); + + var headers = new List>> + { + new KeyValuePair>("x-ld-fd-fallback", new[] { "true" }) + }; + var exception = new EventSourceServiceUnsuccessfulResponseException(503, headers); + _mockEventSource.TriggerError(exception); + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Interrupted, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(503, status.LastError.Value.StatusCode); + Assert.True(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be true when fallback header is present"); + + AssertLogMessageRegex(true, LogLevel.Warn, ".*will retry.*"); + } + } + [Fact] public async Task UnrecoverableHttpErrorStopsInitializationAndShutsDown() { @@ -594,6 +619,111 @@ public async Task UnrecoverableHttpErrorStopsInitializationAndShutsDown() } } + [Fact] + public async Task UnrecoverableHttpErrorWithFallbackHeaderSetsFDv1Fallback() + { + using (var dataSource = MakeDataSource()) + { + var startTask = dataSource.Start(); + + var headers = new List>> + { + new KeyValuePair>("x-ld-fd-fallback", new[] { "true" }) + }; + var exception = new EventSourceServiceUnsuccessfulResponseException(401, headers); + _mockEventSource.TriggerError(exception); + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Off, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(401, status.LastError.Value.StatusCode); + Assert.True(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be true when fallback header is present"); + + var result = await startTask; + Assert.False(result); + Assert.False(dataSource.Initialized); + + AssertLogMessageRegex(true, LogLevel.Error, ".*401.*"); + } + } + + [Fact] + public void RecoverableHttpErrorWithoutFallbackHeaderDoesNotSetFDv1Fallback() + { + using (var dataSource = MakeDataSource()) + { + dataSource.Start(); + + var headers = new List>>(); + var exception = new EventSourceServiceUnsuccessfulResponseException(503, headers); + _mockEventSource.TriggerError(exception); + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Interrupted, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(503, status.LastError.Value.StatusCode); + Assert.False(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be false when fallback header is not present"); + + AssertLogMessageRegex(true, LogLevel.Warn, ".*will retry.*"); + } + } + + [Fact] + public void RecoverableHttpErrorWithFallbackHeaderFalseDoesNotSetFDv1Fallback() + { + using (var dataSource = MakeDataSource()) + { + dataSource.Start(); + + var headers = new List>> + { + new KeyValuePair>("x-ld-fd-fallback", new[] { "false" }) + }; + var exception = new EventSourceServiceUnsuccessfulResponseException(503, headers); + _mockEventSource.TriggerError(exception); + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Interrupted, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(503, status.LastError.Value.StatusCode); + Assert.False(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be false when fallback header value is false"); + + AssertLogMessageRegex(true, LogLevel.Warn, ".*will retry.*"); + } + } + + [Fact] + public async Task UnrecoverableHttpErrorWithFallbackHeaderFalseDoesNotSetFDv1Fallback() + { + using (var dataSource = MakeDataSource()) + { + var startTask = dataSource.Start(); + + var headers = new List>> + { + new KeyValuePair>("x-ld-fd-fallback", new[] { "false" }) + }; + var exception = new EventSourceServiceUnsuccessfulResponseException(401, headers); + _mockEventSource.TriggerError(exception); + + var status = _updateSink.StatusUpdates.ExpectValue(); + Assert.Equal(DataSourceState.Off, status.State); + Assert.NotNull(status.LastError); + Assert.Equal(DataSourceStatus.ErrorKind.ErrorResponse, status.LastError.Value.Kind); + Assert.Equal(401, status.LastError.Value.StatusCode); + Assert.False(status.LastError.Value.FDv1Fallback, "FDv1Fallback should be false when fallback header value is false"); + + var result = await startTask; + Assert.False(result); + Assert.False(dataSource.Initialized); + + AssertLogMessageRegex(true, LogLevel.Error, ".*401.*"); + } + } + [Fact] public void NetworkErrorUpdatesStatusToInterrupted() {