From 6c932e15420249975c06469db68b33606b716bb2 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 3 Sep 2026 15:14:14 -0400 Subject: [PATCH] fix: Avoid NullReferenceException on polling 304 responses FeatureRequestor.GetAllDataAsync() signals an unchanged payload by returning a null DataSetWithHeaders, but PollingDataSource dereferenced dataAndHeaders.DataSet without first checking the wrapper itself. Every poll that received a 304 therefore threw a NullReferenceException, which was swallowed by the generic handler, logged as a warning, and reported as DataSourceState.Interrupted -- leaving a polling client with stable flags permanently in an Interrupted state. The inner DataSet null check is retained since it is harmless, but note that it is currently unreachable: DataSetWithHeaders is only ever constructed with a non-null data set. --- .../Internal/DataSources/PollingDataSource.cs | 2 +- .../DataSources/PollingDataSourceTest.cs | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pkgs/sdk/server/src/Internal/DataSources/PollingDataSource.cs b/pkgs/sdk/server/src/Internal/DataSources/PollingDataSource.cs index 9048c9fa..a71ab630 100644 --- a/pkgs/sdk/server/src/Internal/DataSources/PollingDataSource.cs +++ b/pkgs/sdk/server/src/Internal/DataSources/PollingDataSource.cs @@ -65,7 +65,7 @@ private async Task UpdateTaskAsync() try { var dataAndHeaders = await _featureRequestor.GetAllDataAsync(); - if (dataAndHeaders.DataSet is null) + if (dataAndHeaders is null || dataAndHeaders.DataSet is null) { // This means it was cached, and alreadyInited was true _dataSourceUpdates.UpdateStatus(DataSourceState.Valid, null); diff --git a/pkgs/sdk/server/test/Internal/DataSources/PollingDataSourceTest.cs b/pkgs/sdk/server/test/Internal/DataSources/PollingDataSourceTest.cs index af92f2b7..069e07ff 100644 --- a/pkgs/sdk/server/test/Internal/DataSources/PollingDataSourceTest.cs +++ b/pkgs/sdk/server/test/Internal/DataSources/PollingDataSourceTest.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using LaunchDarkly.Sdk.Server.Interfaces; using LaunchDarkly.Sdk.Server.Internal.DataSystem; using LaunchDarkly.Sdk.Server.Internal.Model; using LaunchDarkly.Sdk.Server.Subsystems; @@ -258,6 +259,41 @@ public void InitIsNotRepeatedIfServerReturnsNotModifiedStatus() } } + [Fact] + public void StatusRemainsValidIfServerReturnsNotModifiedStatus() + { + var etag = @"""abc123"""; // note that etag strings must be quoted + var responses = Handlers.SequentialWithLastRepeating( + Handlers.Header("Etag", etag).Then(PollingResponse(AllData)), + Handlers.Status(304) + ); + + using (var server = HttpServer.Start(responses)) + { + using (var dataSource = MakeDataSource(server.Uri, + c => c.DataSource(Components.PollingDataSource().PollIntervalNoMinimum(BriefInterval)))) + { + dataSource.Start(); + + _updateSink.Inits.ExpectValue(); + server.Recorder.RequireRequest(); + server.Recorder.RequireRequest(); + server.Recorder.RequireRequest(); + + // We've set it up above so that all requests except the first one return a 304 + // status. That just means the data is unchanged, which is a healthy state, so it + // should not be reported as an interruption or logged as a failure. + Assert.All(_updateSink.GetAllStatusUpdates(), status => + { + Assert.Equal(DataSourceState.Valid, status.State); + Assert.Null(status.LastError); + }); + AssertLogMessageRegex(false, Logging.LogLevel.Warn, + "Polling for feature flag updates failed"); + } + } + } + [Fact] public void ResponseWithNewEtagUpdatesEtag() {