Skip to content

Commit ac41228

Browse files
chore: adds FDv1 Fallback support to FDv2 Data Source (#203)
**Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [ ] I have validated my changes against all supported platform versions Will be done as final integration testing <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds FDv1 fallback to FDv2 data sources, triggered by the `x-ld-fd-fallback` header and configured via SDK DataSystem to use a polling fallback synchronizer. > > - **FDv2 data source fallback**: > - Add `FDv1FallbackActionApplier` in `FDv2DataSource` to switch from FDv2 synchronizers to FDv1 synchronizers when `ErrorInfo.FDv1Fallback` is true. > - Include optional FDv1 synchronizers composite in the combined `CompositeSource`. > - **Error signaling**: > - Add `DataSourceStatus.ErrorInfo.FDv1Fallback` flag. > - Set this flag in `FDv2PollingDataSource` and `FDv2StreamingDataSource` when the `x-ld-fd-fallback` header is present on HTTP errors. > - **SDK config wiring (contract tests)**: > - In `SdkClientEntity`, configure FDv1 polling fallback synchronizer derived from the selected synchronizer and align global polling `ServiceEndpoints` when a custom polling base URI is provided. > - **Tests**: > - Add tests covering header parsing, fallback triggering, and FDv1 synchronizer activation across polling/streaming and composite behavior. > - Update `test-supressions-fdv2.txt` to reflect new FDv1 fallback handling. > - **Dependencies**: > - Bump `LaunchDarkly.EventSource` to `5.3.0` and `LaunchDarkly.InternalSdk` to `3.6.0`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 25910ad. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Ryan Lamb <[email protected]>
1 parent 1bb4461 commit ac41228

File tree

11 files changed

+550
-9
lines changed

11 files changed

+550
-9
lines changed

pkgs/sdk/server/contract-tests/SdkClientEntity.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,45 @@ private static Configuration BuildSdkConfig(SdkConfigParams sdkParams, ILogAdapt
514514
if (synchronizers.Count > 0)
515515
{
516516
dataSystemBuilder.Synchronizers(synchronizers.ToArray());
517+
518+
// Find the best synchronizer to use for FDv1 fallback configuration
519+
// Prefer polling synchronizers since FDv1 fallback is polling-based
520+
SdkConfigSynchronizerParams synchronizerForFallback = null;
521+
522+
// First, try to find a polling synchronizer (check secondary first, then primary)
523+
if (sdkParams.DataSystem.Synchronizers.Secondary != null &&
524+
sdkParams.DataSystem.Synchronizers.Secondary.Polling != null)
525+
{
526+
synchronizerForFallback = sdkParams.DataSystem.Synchronizers.Secondary;
527+
}
528+
else if (sdkParams.DataSystem.Synchronizers.Primary != null &&
529+
sdkParams.DataSystem.Synchronizers.Primary.Polling != null)
530+
{
531+
synchronizerForFallback = sdkParams.DataSystem.Synchronizers.Primary;
532+
}
533+
// If no polling synchronizer found, use primary synchronizer (could be streaming)
534+
else if (sdkParams.DataSystem.Synchronizers.Primary != null)
535+
{
536+
synchronizerForFallback = sdkParams.DataSystem.Synchronizers.Primary;
537+
}
538+
539+
if (synchronizerForFallback != null)
540+
{
541+
// Only configure global polling endpoints if we have a polling synchronizer with a custom base URI
542+
// This ensures the FDv1 fallback synchronizer uses the same base URI without overwriting
543+
// existing polling endpoint configuration
544+
if (synchronizerForFallback.Polling != null &&
545+
synchronizerForFallback.Polling.BaseUri != null)
546+
{
547+
endpoints.Polling(synchronizerForFallback.Polling.BaseUri);
548+
}
549+
550+
var fdv1Fallback = CreateFDv1FallbackSynchronizer(synchronizerForFallback);
551+
if (fdv1Fallback != null)
552+
{
553+
dataSystemBuilder.FDv1FallbackSynchronizer(fdv1Fallback);
554+
}
555+
}
517556
}
518557
}
519558

@@ -568,6 +607,33 @@ private static IComponentConfigurer<IDataSource> CreateSynchronizer(
568607
return null;
569608
}
570609

610+
private static IComponentConfigurer<IDataSource> CreateFDv1FallbackSynchronizer(
611+
SdkConfigSynchronizerParams synchronizer)
612+
{
613+
// FDv1 fallback synchronizer is always polling-based
614+
var fdv1PollingBuilder = DataSystemComponents.FDv1Polling();
615+
616+
// Configure polling interval if the synchronizer has polling configuration
617+
if (synchronizer.Polling != null)
618+
{
619+
if (synchronizer.Polling.PollIntervalMs.HasValue)
620+
{
621+
fdv1PollingBuilder.PollInterval(TimeSpan.FromMilliseconds(synchronizer.Polling.PollIntervalMs.Value));
622+
}
623+
// Note: FDv1 polling doesn't support ServiceEndpointsOverride, so base URI
624+
// will use the global service endpoints configuration
625+
}
626+
else if (synchronizer.Streaming != null)
627+
{
628+
// For streaming synchronizers, we still create a polling fallback
629+
// Use default polling interval since streaming doesn't have a poll interval
630+
// Note: FDv1 polling doesn't support ServiceEndpointsOverride, so base URI
631+
// will use the global service endpoints configuration
632+
}
633+
634+
return fdv1PollingBuilder;
635+
}
636+
571637
private MigrationVariationResponse DoMigrationVariation(MigrationVariationParams migrationVariation)
572638
{
573639
var defaultStage = MigrationStageExtensions.FromDataModelString(migrationVariation.DefaultStage);

pkgs/sdk/server/contract-tests/test-supressions-fdv2.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,5 @@ streaming/retry behavior/do not retry after unrecoverable HTTP error on reconnec
55
streaming/retry behavior/do not retry after unrecoverable HTTP error on reconnect/error 403
66
streaming/retry behavior/do not retry after unrecoverable HTTP error on reconnect/error 405
77
streaming/fdv2/reconnection state management/initializes from 2 polling initializers
8-
streaming/fdv2/fallback to FDv1 handling
98
streaming/fdv2/disconnects on goodbye
109
streaming/fdv2/reconnection state management/initializes from polling initializer

pkgs/sdk/server/src/Interfaces/DataSourceStatus.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ public struct ErrorInfo
8080
/// </summary>
8181
public DateTime Time { get; set; }
8282

83+
/// <summary>
84+
/// The error indicates to fall back to FDv1. (At the time of writing this, this was indicated
85+
/// via the x-ld-fd-fallback header, but this may change in the future. This is just info for posterity.)
86+
/// </summary>
87+
public bool FDv1Fallback { get; set; }
88+
8389
/// <summary>
8490
/// Constructs an instance based on an exception.
8591
/// </summary>

pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2DataSource.cs

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Threading.Tasks;
55
using LaunchDarkly.Sdk.Server.Interfaces;
66
using LaunchDarkly.Sdk.Server.Subsystems;
7+
78
using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes;
89

910
namespace LaunchDarkly.Sdk.Server.Internal.DataSources
@@ -16,7 +17,7 @@ internal static class FDv2DataSource
1617
/// <param name="updatesSink">the sink that receives updates from the active source</param>
1718
/// <param name="initializers">List of data source factories used for initialization</param>
1819
/// <param name="synchronizers">List of data source factories used for synchronization</param>
19-
/// <param name="fdv1Synchronizers">List of data source factories used for FDv1 synchronization</param>
20+
/// <param name="fdv1Synchronizers">List of data source factories used for FDv1 synchronization if fallback to FDv1 occurs</param>
2021
/// <returns>a new data source instance</returns>
2122
public static IDataSource CreateFDv2DataSource(
2223
IDataSourceUpdatesV2 updatesSink,
@@ -31,7 +32,7 @@ public static IDataSource CreateFDv2DataSource(
3132
ActionApplierFactory fastFallbackApplierFactory = (actionable) => new ActionApplierFastFallback(actionable);
3233
ActionApplierFactory timedFallbackAndRecoveryApplierFactory =
3334
(actionable) => new ActionApplierTimedFallbackAndRecovery(actionable);
34-
35+
ActionApplierFactory fdv1FallbackApplierFactory = (actionable) => new FDv1FallbackActionApplier(actionable);
3536
var underlyingComposites = new List<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory)>();
3637

3738
// Only create the initializers composite if initializers are provided
@@ -71,13 +72,32 @@ public static IDataSource CreateFDv2DataSource(
7172

7273
return new CompositeSource(sink as IDataSourceUpdatesV2, synchronizersFactoryTuples);
7374
},
74-
null // TODO: add fallback to FDv1 logic, null for the moment as once we're on the synchronizers, we stay there
75+
// Only attach FDv1 fallback applier if FDv1 synchronizers are actually provided
76+
(fdv1Synchronizers != null && fdv1Synchronizers.Count > 0) ? fdv1FallbackApplierFactory : null
7577
));
7678
}
7779

78-
var combinedCompositeSource = new CompositeSource(updatesSink, underlyingComposites, circular: false);
80+
// Add the FDv1 fallback synchronizers composite if provided
81+
if (fdv1Synchronizers != null && fdv1Synchronizers.Count > 0)
82+
{
83+
underlyingComposites.Add((
84+
// Create fdv1SynchronizersCompositeSource with action logic unique to fdv1Synchronizers
85+
(sink) =>
86+
{
87+
var fdv1SynchronizersFactoryTuples =
88+
new List<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory)>();
89+
for (int i = 0; i < fdv1Synchronizers.Count; i++)
90+
{
91+
fdv1SynchronizersFactoryTuples.Add((fdv1Synchronizers[i], timedFallbackAndRecoveryApplierFactory)); // fdv1 synchronizers behave same as synchronizers
92+
}
93+
94+
return new CompositeSource(sink as IDataSourceUpdatesV2, fdv1SynchronizersFactoryTuples);
95+
},
96+
null // no action applier for fdv1Synchronizers as a whole
97+
));
98+
}
7999

80-
// TODO: add fallback to FDv1 logic
100+
var combinedCompositeSource = new CompositeSource(updatesSink, underlyingComposites, circular: false);
81101

82102
return combinedCompositeSource;
83103
}
@@ -265,5 +285,31 @@ public void Apply(ChangeSet<ItemDescriptor> changeSet)
265285
_actionable.StartCurrent();
266286
}
267287
}
288+
289+
private class FDv1FallbackActionApplier : IDataSourceObserver
290+
{
291+
private readonly ICompositeSourceActionable _actionable;
292+
293+
public FDv1FallbackActionApplier(ICompositeSourceActionable actionable)
294+
{
295+
_actionable = actionable ?? throw new ArgumentNullException(nameof(actionable));
296+
}
297+
298+
public void UpdateStatus(DataSourceState newState, DataSourceStatus.ErrorInfo? newError)
299+
{
300+
if (newError != null && newError.Value.FDv1Fallback)
301+
{
302+
_actionable.BlacklistCurrent(); // blacklist the synchronizers altogether
303+
_actionable.DisposeCurrent(); // dispose the synchronizers
304+
_actionable.GoToNext(); // go to the FDv1 fallback synchronizer
305+
_actionable.StartCurrent(); // start the FDv1 fallback synchronizer
306+
}
307+
}
308+
309+
public void Apply(ChangeSet<ItemDescriptor> changeSet)
310+
{
311+
// this FDv1 fallback action applier doesn't care about apply, it only looks for the FDv1Fallback flag in the errors
312+
}
313+
}
268314
}
269315
}

pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingDataSource.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ private async Task UpdateTaskAsync()
9696
catch (UnsuccessfulResponseException ex)
9797
{
9898
var errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(ex.StatusCode);
99+
100+
// Check for LD fallback header
101+
if (ex.Headers != null)
102+
{
103+
errorInfo.FDv1Fallback = ex.Headers
104+
.Where(h => string.Equals(h.Key, "x-ld-fd-fallback", StringComparison.OrdinalIgnoreCase))
105+
.SelectMany(h => h.Value)
106+
.Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase));
107+
}
99108

100109
if (HttpErrors.IsRecoverable(ex.StatusCode))
101110
{

pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2PollingRequestor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ internal FDv2PollingRequestor(LdClientContext context, Uri baseUri)
105105

106106
if (!response.IsSuccessStatusCode)
107107
{
108-
throw new UnsuccessfulResponseException((int)response.StatusCode);
108+
throw new UnsuccessfulResponseException((int)response.StatusCode, response.Headers);
109109
}
110110

111111
lock (_etags)

pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2StreamingDataSource.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,16 @@ private void OnError(object sender, ExceptionEventArgs e)
275275
{
276276
var status = respEx.StatusCode;
277277
errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(status);
278+
279+
// Check for LD fallback header
280+
if (respEx.Headers != null)
281+
{
282+
errorInfo.FDv1Fallback = respEx.Headers
283+
.Where(h => string.Equals(h.Key, "x-ld-fd-fallback", StringComparison.OrdinalIgnoreCase))
284+
.SelectMany(h => h.Value)
285+
.Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase));
286+
}
287+
278288
RecordStreamInit(true);
279289
if (!HttpErrors.IsRecoverable(status))
280290
{

pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
<ItemGroup>
4141
<PackageReference Include="LaunchDarkly.Cache" Version="1.0.2" />
4242
<PackageReference Include="LaunchDarkly.CommonSdk" Version="7.1.1" />
43-
<PackageReference Include="LaunchDarkly.EventSource" Version="5.2.1" />
44-
<PackageReference Include="LaunchDarkly.InternalSdk" Version="3.5.5" />
43+
<PackageReference Include="LaunchDarkly.EventSource" Version="5.3.0" />
44+
<PackageReference Include="LaunchDarkly.InternalSdk" Version="3.6.0" />
4545
<PackageReference Include="LaunchDarkly.Logging" Version="2.0.0" />
4646
</ItemGroup>
4747

pkgs/sdk/server/test/Internal/FDv2DataSources/FDv2DataSourceTest.cs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,133 @@ public async Task CanDisposeWhenSynchronizersFallingBackUnthrottled()
10451045
// The result may be true or false depending on implementation, but the key is that disposal works
10461046
}
10471047

1048+
[Fact]
1049+
public async Task ErrorWithFDv1FallbackTriggersFallbackToFDv1Synchronizers()
1050+
{
1051+
// Create a capturing sink to observe all updates
1052+
var capturingSink = new CapturingDataSourceUpdatesWithHeaders();
1053+
1054+
// Track whether the synchronizer factory was invoked
1055+
bool synchronizerFactoryInvoked = false;
1056+
IDataSourceUpdatesV2 synchronizerUpdateSink = null;
1057+
1058+
// Create synchronizer factory: emits Initializing, then reports Interrupted with FDv1Fallback error
1059+
SourceFactory synchronizerFactory = (updatesSink) =>
1060+
{
1061+
synchronizerFactoryInvoked = true;
1062+
synchronizerUpdateSink = updatesSink;
1063+
var source = new MockDataSourceWithInit(
1064+
async () =>
1065+
{
1066+
// Emit Initializing
1067+
updatesSink.UpdateStatus(DataSourceState.Initializing, null);
1068+
await Task.Delay(10);
1069+
1070+
// Report Interrupted with error that has FDv1Fallback = true
1071+
var errorInfo = new DataSourceStatus.ErrorInfo
1072+
{
1073+
Kind = DataSourceStatus.ErrorKind.ErrorResponse,
1074+
StatusCode = 503,
1075+
FDv1Fallback = true,
1076+
Time = DateTime.Now
1077+
};
1078+
updatesSink.UpdateStatus(DataSourceState.Interrupted, errorInfo);
1079+
await Task.Delay(10);
1080+
}
1081+
);
1082+
return source;
1083+
};
1084+
1085+
// Track whether the fdv1Synchronizer factory was invoked
1086+
bool fdv1SynchronizerFactoryInvoked = false;
1087+
IDataSourceUpdatesV2 fdv1SynchronizerUpdateSink = null;
1088+
1089+
// Create dummy data for fdv1Synchronizer
1090+
var fdv1SynchronizerDummyData = new FullDataSet<ItemDescriptor>(new Dictionary<DataKind, KeyedItems<ItemDescriptor>>());
1091+
1092+
// Create fdv1Synchronizer factory: emits Initializing, calls init with dummy data, then reports Valid
1093+
SourceFactory fdv1SynchronizerFactory = (updatesSink) =>
1094+
{
1095+
fdv1SynchronizerFactoryInvoked = true;
1096+
fdv1SynchronizerUpdateSink = updatesSink;
1097+
var source = new MockDataSourceWithInit(
1098+
async () =>
1099+
{
1100+
// Emit Initializing
1101+
updatesSink.UpdateStatus(DataSourceState.Initializing, null);
1102+
await Task.Delay(10);
1103+
1104+
// Report Valid
1105+
updatesSink.UpdateStatus(DataSourceState.Valid, null);
1106+
await Task.Delay(10);
1107+
1108+
// Call Apply with dummy data
1109+
updatesSink.Apply(new ChangeSet<ItemDescriptor>(
1110+
ChangeSetType.Full,
1111+
Selector.Empty,
1112+
fdv1SynchronizerDummyData.Data,
1113+
null
1114+
));
1115+
await Task.Delay(10);
1116+
}
1117+
);
1118+
return source;
1119+
};
1120+
1121+
// Create FDv2DataSource with no initializers, one synchronizer, and one fdv1Synchronizer
1122+
var initializers = new List<SourceFactory>();
1123+
var synchronizers = new List<SourceFactory> { synchronizerFactory };
1124+
var fdv1Synchronizers = new List<SourceFactory> { fdv1SynchronizerFactory };
1125+
1126+
var dataSource = FDv2DataSource.CreateFDv2DataSource(
1127+
capturingSink,
1128+
initializers,
1129+
synchronizers,
1130+
fdv1Synchronizers
1131+
);
1132+
1133+
// Start the data source
1134+
var startTask = dataSource.Start();
1135+
1136+
// Wait for status updates - we expect:
1137+
// 1. Initializing (from synchronizer)
1138+
// 2. Interrupted (from synchronizer with FDv1Fallback error)
1139+
// 3. Interrupted (initializing from fdv1Synchronizer is mapped to interrupted by sanitizer after fallback)
1140+
// 4. Valid (from fdv1Synchronizer)
1141+
var statusUpdates = capturingSink.WaitForStatusUpdates(4, TimeSpan.FromSeconds(5));
1142+
1143+
// Verify that Start() completed successfully
1144+
var startResult = await startTask;
1145+
Assert.True(startResult);
1146+
1147+
// Verify status updates
1148+
Assert.True(statusUpdates.Count >= 4, $"Expected at least 4 status updates, got {statusUpdates.Count}");
1149+
1150+
// Position 0: Initializing (from synchronizer)
1151+
Assert.Equal(DataSourceState.Initializing, statusUpdates[0].State);
1152+
1153+
// Position 1: Interrupted (from synchronizer with FDv1Fallback error)
1154+
Assert.Equal(DataSourceState.Interrupted, statusUpdates[1].State);
1155+
Assert.NotNull(statusUpdates[1].LastError);
1156+
Assert.True(statusUpdates[1].LastError.Value.FDv1Fallback, "FDv1Fallback should be true in the error");
1157+
1158+
// Position 2: Interrupted (initializing from fdv1Synchronizer is mapped to interrupted by sanitizer after fallback)
1159+
Assert.Equal(DataSourceState.Interrupted, statusUpdates[2].State);
1160+
1161+
// Position 3: Valid (from fdv1Synchronizer)
1162+
Assert.Equal(DataSourceState.Valid, statusUpdates[3].State);
1163+
1164+
// Verify that both factories were invoked
1165+
Assert.True(synchronizerFactoryInvoked, "Synchronizer factory should have been invoked");
1166+
Assert.True(fdv1SynchronizerFactoryInvoked, "FDv1Synchronizer factory should have been invoked after fallback");
1167+
1168+
// Verify that Apply was called with fdv1Synchronizer dummy data
1169+
var changeSet = capturingSink.Applies.ExpectValue(TimeSpan.FromSeconds(1));
1170+
Assert.Equal(ChangeSetType.Full, changeSet.Type);
1171+
1172+
dataSource.Dispose();
1173+
}
1174+
10481175

10491176
// Mock data source that executes an async action when started
10501177
private class MockDataSourceWithInit : IDataSource

0 commit comments

Comments
 (0)