From 094a3e58e47a012f25c78284106e4a57f37d6a87 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 19:21:58 -0700 Subject: [PATCH 1/9] fix: validate filter key and join paths cleanly in FDv2 polling --- .../data_systems/fdv2/fdv2_polling_impl.cpp | 9 ++++-- .../tests/fdv2_polling_impl_test.cpp | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 260153737..1ea831611 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -1,4 +1,5 @@ #include "fdv2_polling_impl.hpp" +#include "../background_sync/detail/payload_filter_validation/payload_filter_validation.hpp" #include "fdv2_changeset_translation.hpp" #include @@ -11,7 +12,6 @@ namespace launchdarkly::server_side::data_systems { -static char const* const kFDv2PollPath = "/sdk/poll"; static char const* const kFDv1FallbackHeader = "X-LD-FD-Fallback"; static char const* const kErrorParsingBody = @@ -54,11 +54,14 @@ network::HttpRequest MakeFDv2PollRequest( } boost::urls::url u = parsed.value(); - u.set_path(u.path() + kFDv2PollPath); + // Use segments to join the path; string concatenation would produce a + // double slash when the base URL ends in '/'. + u.segments().push_back("sdk"); + u.segments().push_back("poll"); if (selector.value) { u.params().append({"basis", selector.value->state}); } - if (filter_key) { + if (filter_key && detail::ValidateFilterKey(*filter_key)) { u.params().append({"filter", *filter_key}); } diff --git a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp index b4ec5c332..0f925f571 100644 --- a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp +++ b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -107,3 +108,33 @@ TEST(HandleFDv2PollResponseTest, NetworkErrorDoesNotSetFlag) { std::holds_alternative(result.value)); EXPECT_FALSE(result.fdv1_fallback); } + +TEST(MakeFDv2PollRequestTest, BaseWithTrailingSlashDoesNotProduceDoubleSlash) { + config::shared::built::ServiceEndpoints endpoints{"http://example.com/", "", + ""}; + auto props = + config::shared::Defaults::HttpProperties(); + auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, + std::nullopt); + EXPECT_EQ(req.Url(), "http://example.com/sdk/poll"); +} + +TEST(MakeFDv2PollRequestTest, ValidFilterKeyIsIncluded) { + config::shared::built::ServiceEndpoints endpoints{"http://example.com", "", + ""}; + auto props = + config::shared::Defaults::HttpProperties(); + auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, + std::string{"my-filter_1.0"}); + EXPECT_EQ(req.Url(), "http://example.com/sdk/poll?filter=my-filter_1.0"); +} + +TEST(MakeFDv2PollRequestTest, InvalidFilterKeyIsDropped) { + config::shared::built::ServiceEndpoints endpoints{"http://example.com", "", + ""}; + auto props = + config::shared::Defaults::HttpProperties(); + auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, + std::string{"has spaces"}); + EXPECT_EQ(req.Url(), "http://example.com/sdk/poll"); +} From a1f18639952336ed6ea0ec75f9545c80dc64c40e Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 19:22:10 -0700 Subject: [PATCH 2/9] fix: validate filter key in FDv2 streaming on_connect --- .../fdv2/streaming_synchronizer.cpp | 3 ++- .../fdv2_streaming_synchronizer_test.cpp | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp index a87a648f2..8f232e38b 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp @@ -1,4 +1,5 @@ #include "streaming_synchronizer.hpp" +#include "../background_sync/detail/payload_filter_validation/payload_filter_validation.hpp" #include "fdv2_changeset_translation.hpp" #include @@ -167,7 +168,7 @@ void FDv2StreamingSynchronizer::State::OnConnect(HttpRequest* req) { if (latest_selector_.value) { u.params().set("basis", latest_selector_.value->state); } - if (filter_key_) { + if (filter_key_ && detail::ValidateFilterKey(*filter_key_)) { u.params().set("filter", *filter_key_); } req->target(u.encoded_target()); diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index 115209eda..8a2ea49f3 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -294,6 +294,29 @@ TEST(FDv2StreamingSynchronizerTest, OnConnectWithFilterKeyAppendsFilter) { EXPECT_EQ(req.target(), "/sdk/stream?filter=my-filter"); } +TEST(FDv2StreamingSynchronizerTest, OnConnectInvalidFilterKeyIsDropped) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeEndpoints("https://stream.example.com"), MakeHttpProperties(), + std::string("has spaces"), 1s); + + boost::urls::url base = + boost::urls::parse_uri("https://stream.example.com").value(); + base.segments().push_back("sdk"); + base.segments().push_back("stream"); + FDv2StreamingSynchronizerTestPeer::SetBaseUrl(synchronizer, base); + + boost::beast::http::request req; + + FDv2StreamingSynchronizerTestPeer::OnConnect(synchronizer, &req); + + // A filter key that fails validation is silently dropped from the URL. + EXPECT_EQ(req.target(), "/sdk/stream"); +} + TEST(FDv2StreamingSynchronizerTest, OnConnectReconnectUsesLatestSelector) { auto logger = MakeNullLogger(); IoContextRunner runner; From ab905c4e4e58c19cc4a68909c078ec7f52008302 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 21:05:56 -0700 Subject: [PATCH 3/9] fix: keep FDv2 protocol handler ready after payload-transferred --- libs/internal/src/fdv2_protocol_handler.cpp | 11 +++----- .../tests/fdv2_protocol_handler_test.cpp | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index 13b8e6823..2b0ea6dc5 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -78,9 +78,6 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleServerIntent( FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandlePutObject( boost::json::value const& data) { - if (state_ == State::kInactive) { - return std::monostate{}; - } auto result = boost::json::value_to< tl::expected, JsonError>>(data); if (!result) { @@ -101,9 +98,6 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandlePutObject( FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleDeleteObject( boost::json::value const& data) { - if (state_ == State::kInactive) { - return std::monostate{}; - } auto result = boost::json::value_to< tl::expected, JsonError>>(data); if (!result) { @@ -152,7 +146,10 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandlePayloadTransferred( type, std::move(changes_), data_model::Selector{data_model::Selector::State{transferred.version, transferred.state}}}; - Reset(); + // Transition to kPartial so subsequent put-object/payload-transferred + // cycles work without requiring a new server-intent. + changes_.clear(); + state_ = State::kPartial; return changeset; } diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index fb428c243..5537e09ff 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -353,3 +353,30 @@ TEST(FDv2ProtocolHandlerTest, PayloadTransferredWithoutServerIntentIsError) { ASSERT_NE(err, nullptr); EXPECT_EQ(err->kind, FDv2ProtocolHandler::Error::Kind::kProtocolError); } + +TEST(FDv2ProtocolHandlerTest, ConsecutivePayloadsWithoutNewServerIntent) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + handler.HandleEvent("put-object", + MakePutObject("flag", "first", kFlagJson)); + auto first = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s1", 1)); + auto* cs1 = std::get_if(&first); + ASSERT_NE(cs1, nullptr); + ASSERT_EQ(cs1->changes.size(), 1u); + EXPECT_EQ(cs1->changes[0].key, "first"); + + // A subsequent payload arrives without an intervening server-intent + // (streaming incremental update). Java's state machine transitions to + // CHANGES after payload-transferred so this case continues to work. + handler.HandleEvent("put-object", + MakePutObject("flag", "second", kFlagJson)); + auto second = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s2", 2)); + auto* cs2 = std::get_if(&second); + ASSERT_NE(cs2, nullptr); + ASSERT_EQ(cs2->changes.size(), 1u); + EXPECT_EQ(cs2->changes[0].key, "second"); + EXPECT_EQ(cs2->type, data_model::ChangeSetType::kPartial); +} From b1e87b679dc56dcdade4fbcf2f2273d94cf1cb08 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 21:40:03 -0700 Subject: [PATCH 4/9] fix: restart FDv2 stream on parse or protocol error --- .../fdv2/streaming_synchronizer.cpp | 8 +++++ .../fdv2_streaming_synchronizer_test.cpp | 35 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp index 8f232e38b..6e9642252 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp @@ -192,6 +192,10 @@ void FDv2StreamingSynchronizer::State::OnEvent(sse::Event const& event) { LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; Notify(FDv2SourceResult{FDv2SourceResult::Interrupted{ MakeError(ErrorKind::kInvalidData, 0, std::move(msg))}}); + std::lock_guard lock(mutex_); + if (sse_client_) { + sse_client_->async_restart("FDv2 parse error"); + } return; } @@ -251,6 +255,10 @@ void FDv2StreamingSynchronizer::State::OnEvent(sse::Event const& event) { << kIdentity << ": " << r.message; Notify(FDv2SourceResult{FDv2SourceResult::Interrupted{ MakeError(ErrorKind::kInvalidData, 0, r.message)}}); + std::lock_guard lock(mutex_); + if (sse_client_) { + sse_client_->async_restart("FDv2 protocol error"); + } } else { static_assert(always_false_v, "non-exhaustive visitor"); } diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index 8a2ea49f3..3191921f7 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -584,6 +584,9 @@ TEST(FDv2StreamingSynchronizerTest, MalformedJsonEventReturnsInterrupted) { 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); + auto mock_client = std::make_shared(); + FDv2StreamingSynchronizerTestPeer::SetSseClient(synchronizer, mock_client); + sse::Event bad_event("server-intent", "this is not json"); // Act: deliver an event whose data field cannot be parsed as JSON. @@ -592,13 +595,43 @@ TEST(FDv2StreamingSynchronizerTest, MalformedJsonEventReturnsInterrupted) { auto result = future.WaitForResult(2s); // Assert: the synchronizer reports Interrupted{kInvalidData} so the - // orchestrator knows the stream produced unparseable bytes. + // orchestrator knows the stream produced unparseable bytes, and drives + // the SSE client to restart so the next connection starts clean. ASSERT_TRUE(result.has_value()); auto* interrupted = std::get_if(&result->value); ASSERT_NE(interrupted, nullptr); EXPECT_EQ(interrupted->error.Kind(), FDv2SourceResult::ErrorInfo::ErrorKind::kInvalidData); + EXPECT_EQ(mock_client->restart_count_, 1); +} + +TEST(FDv2StreamingSynchronizerTest, + SchemaViolationServerIntentTriggersRestart) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, + 1s); + FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); + + auto mock_client = std::make_shared(); + FDv2StreamingSynchronizerTestPeer::SetSseClient(synchronizer, mock_client); + + // Well-formed JSON, but the shape doesn't match a server-intent payload. + sse::Event bad_event("server-intent", + R"({"data":{"flags":true,"segments":{}}})"); + + FDv2StreamingSynchronizerTestPeer::OnEvent(synchronizer, bad_event); + auto future = synchronizer.Next(data_model::Selector{}); + auto result = future.WaitForResult(2s); + + ASSERT_TRUE(result.has_value()); + ASSERT_NE(std::get_if(&result->value), + nullptr); + EXPECT_EQ(mock_client->restart_count_, 1); } TEST(FDv2StreamingSynchronizerTest, TranslationFailureReturnsInterrupted) { From ef81233a5e3aa19428401d934f4c241f28530f4f Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 22:28:16 -0700 Subject: [PATCH 5/9] fix: drop trailing empty segment before appending FDv2 path --- .../src/data_systems/fdv2/fdv2_polling_impl.cpp | 12 ++++++++---- .../data_systems/fdv2/streaming_synchronizer.cpp | 16 ++++++++++++---- libs/server-sdk/tests/fdv2_polling_impl_test.cpp | 10 ++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 1ea831611..5a98a02b2 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -54,10 +54,14 @@ network::HttpRequest MakeFDv2PollRequest( } boost::urls::url u = parsed.value(); - // Use segments to join the path; string concatenation would produce a - // double slash when the base URL ends in '/'. - u.segments().push_back("sdk"); - u.segments().push_back("poll"); + // A trailing '/' on the base URL appears as an empty final segment; + // remove it so subsequent push_backs don't produce a double slash. + auto segs = u.segments(); + if (!segs.empty() && segs.back().empty()) { + segs.pop_back(); + } + segs.push_back("sdk"); + segs.push_back("poll"); if (selector.value) { u.params().append({"basis", selector.value->state}); } diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp index 6e9642252..e6bc0dceb 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp @@ -75,10 +75,14 @@ void FDv2StreamingSynchronizer::State::EnsureStarted( boost::urls::url u = parsed.value(); - // Safer way of appending /sdk/stream than string concatenation: avoids - // double slashes if the base URL has a trailing slash. - u.segments().push_back("sdk"); - u.segments().push_back("stream"); + // A trailing '/' on the base URL appears as an empty final segment; + // remove it so subsequent push_backs don't produce a double slash. + auto segs = u.segments(); + if (!segs.empty() && segs.back().empty()) { + segs.pop_back(); + } + segs.push_back("sdk"); + segs.push_back("stream"); // basis and filter are not added here — they are appended per-connect by // the on_connect hook (OnConnect), so that each (re)connection uses the @@ -184,6 +188,10 @@ void FDv2StreamingSynchronizer::State::OnResponse( } void FDv2StreamingSynchronizer::State::OnEvent(sse::Event const& event) { + if (!FDv2ProtocolHandler::IsKnownEvent(event.type())) { + return; + } + boost::system::error_code ec; auto data = boost::json::parse(event.data(), ec); if (ec) { diff --git a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp index 0f925f571..99cd536f9 100644 --- a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp +++ b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp @@ -119,6 +119,16 @@ TEST(MakeFDv2PollRequestTest, BaseWithTrailingSlashDoesNotProduceDoubleSlash) { EXPECT_EQ(req.Url(), "http://example.com/sdk/poll"); } +TEST(MakeFDv2PollRequestTest, BaseWithSubpathTrailingSlashJoinsCleanly) { + config::shared::built::ServiceEndpoints endpoints{ + "http://example.com/relay/", "", ""}; + auto props = + config::shared::Defaults::HttpProperties(); + auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, + std::nullopt); + EXPECT_EQ(req.Url(), "http://example.com/relay/sdk/poll"); +} + TEST(MakeFDv2PollRequestTest, ValidFilterKeyIsIncluded) { config::shared::built::ServiceEndpoints endpoints{"http://example.com", "", ""}; From 4ffd417b7cdaddae3e76b95da9919f323b41bf52 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 22:28:31 -0700 Subject: [PATCH 6/9] fix: skip parsing of unrecognized FDv2 stream events --- .../launchdarkly/fdv2_protocol_handler.hpp | 7 +++++++ libs/internal/src/fdv2_protocol_handler.cpp | 6 ++++++ .../fdv2_streaming_synchronizer_test.cpp | 20 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp index 11cfa7e4e..d5bf7f6b0 100644 --- a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp +++ b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp @@ -95,6 +95,13 @@ class FDv2ProtocolHandler { */ void Reset(); + /** + * @return true if event_type is one that the protocol handler recognizes + * and may dispatch on. Events outside this set are spec-defined as + * "unrecognized data that can be safely ignored". + */ + static bool IsKnownEvent(std::string_view event_type); + FDv2ProtocolHandler() = default; private: diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index 2b0ea6dc5..6fe6c8c34 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -14,6 +14,12 @@ static char const* const kGoodbye = "goodbye"; using Error = FDv2ProtocolHandler::Error; +bool FDv2ProtocolHandler::IsKnownEvent(std::string_view event_type) { + return event_type == kServerIntent || event_type == kPutObject || + event_type == kDeleteObject || event_type == kPayloadTransferred || + event_type == kError || event_type == kGoodbye; +} + FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( std::string_view event_type, boost::json::value const& data) { diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index 3191921f7..68687adb5 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -574,6 +574,26 @@ TEST(FDv2StreamingSynchronizerTest, ServerErrorEventReturnsInterrupted) { std::string::npos); } +TEST(FDv2StreamingSynchronizerTest, UnknownEventWithGarbageBodyIsIgnored) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, + 1s); + FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); + + auto mock_client = std::make_shared(); + FDv2StreamingSynchronizerTestPeer::SetSseClient(synchronizer, mock_client); + + sse::Event unknown_with_garbage("whatever", "not json"); + FDv2StreamingSynchronizerTestPeer::OnEvent(synchronizer, + unknown_with_garbage); + + EXPECT_EQ(mock_client->restart_count_, 0); +} + TEST(FDv2StreamingSynchronizerTest, MalformedJsonEventReturnsInterrupted) { auto logger = MakeNullLogger(); IoContextRunner runner; From 2038ef7cec48e53c18eae814974ffc7dc313ee1d Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 22:51:14 -0700 Subject: [PATCH 7/9] fix: preserve FDv2 protocol state across error events --- libs/internal/src/fdv2_protocol_handler.cpp | 4 +++- .../tests/fdv2_protocol_handler_test.cpp | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index 6fe6c8c34..c07b7fbfe 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -163,7 +163,9 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleError( boost::json::value const& data) { auto result = boost::json::value_to< tl::expected, JsonError>>(data); - Reset(); + // Discard any partial-payload accumulation but keep state intact so + // the next put-object/payload-transferred cycle continues normally. + changes_.clear(); if (!result) { return Error::JsonParseError(result.error(), "could not deserialize error event"); diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index 5537e09ff..74cae4a6d 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -235,6 +235,29 @@ TEST(FDv2ProtocolHandlerTest, EXPECT_TRUE(cs->changes.empty()); } +TEST(FDv2ProtocolHandlerTest, ErrorMidPayloadDiscardsPartialAcceptsSubsequent) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + handler.HandleEvent("put-object", + MakePutObject("flag", "abandoned", kFlagJson)); + handler.HandleEvent( + "error", boost::json::parse(R"({"reason":"something went wrong"})")); + + // After the error, a fresh put + payload-transferred (without an + // intervening server-intent) emits a changeset containing only the + // post-error put. + handler.HandleEvent("put-object", + MakePutObject("flag", "fresh", kFlagJson)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s", 1)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + ASSERT_EQ(cs->changes.size(), 1u); + EXPECT_EQ(cs->changes[0].key, "fresh"); +} + TEST(FDv2ProtocolHandlerTest, ErrorEventWithIdSetsServerId) { FDv2ProtocolHandler handler; From 61174d8b1d00d0395d574e1a1ee19b8903e69ce7 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 4 Jun 2026 15:47:45 -0700 Subject: [PATCH 8/9] docs: clarify FDv2 protocol handler cycle in class doc and test --- .../include/launchdarkly/fdv2_protocol_handler.hpp | 7 +++++-- libs/internal/tests/fdv2_protocol_handler_test.cpp | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp index d5bf7f6b0..77b144935 100644 --- a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp +++ b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp @@ -14,8 +14,11 @@ namespace launchdarkly { /** * Protocol state machine for the FDv2 wire format. * - * Accumulates put-object and delete-object events between a server-intent - * and payload-transferred event, then emits a complete FDv2ChangeSet. + * A server-intent opens a transfer cycle: put-object and delete-object + * events accumulate until a payload-transferred event, which emits an + * FDv2ChangeSet. The handler then remains active — subsequent put/delete + * + payload-transferred cycles emit kPartial changesets reusing the prior + * intent, until the server sends a new server-intent, error, or goodbye. * * Shared between the polling and streaming synchronizers. */ diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index 74cae4a6d..9e0cc8e18 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -391,8 +391,8 @@ TEST(FDv2ProtocolHandlerTest, ConsecutivePayloadsWithoutNewServerIntent) { EXPECT_EQ(cs1->changes[0].key, "first"); // A subsequent payload arrives without an intervening server-intent - // (streaming incremental update). Java's state machine transitions to - // CHANGES after payload-transferred so this case continues to work. + // (streaming incremental update): the handler emits a kPartial + // changeset reusing the prior intent. handler.HandleEvent("put-object", MakePutObject("flag", "second", kFlagJson)); auto second = handler.HandleEvent("payload-transferred", From 3488e09ff9888b81eee199a2bb4818dbdee07392 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 4 Jun 2026 16:34:00 -0700 Subject: [PATCH 9/9] chore: log error when FDv2 filter key is invalid --- .../src/data_systems/fdv2/fdv2_polling_impl.cpp | 13 ++++++++++--- .../src/data_systems/fdv2/fdv2_polling_impl.hpp | 3 ++- .../src/data_systems/fdv2/polling_initializer.cpp | 3 ++- .../src/data_systems/fdv2/polling_synchronizer.cpp | 2 +- .../data_systems/fdv2/streaming_synchronizer.cpp | 10 ++++++++-- libs/server-sdk/tests/fdv2_polling_impl_test.cpp | 12 ++++++++---- 6 files changed, 31 insertions(+), 12 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 5a98a02b2..1178aff46 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -44,7 +44,8 @@ network::HttpRequest MakeFDv2PollRequest( config::built::ServiceEndpoints const& endpoints, config::built::HttpProperties const& http_properties, data_model::Selector const& selector, - std::optional const& filter_key) { + std::optional const& filter_key, + Logger const& logger) { config::builders::HttpPropertiesBuilder const builder(http_properties); auto parsed = boost::urls::parse_uri(endpoints.PollingBaseUrl()); @@ -65,8 +66,14 @@ network::HttpRequest MakeFDv2PollRequest( if (selector.value) { u.params().append({"basis", selector.value->state}); } - if (filter_key && detail::ValidateFilterKey(*filter_key)) { - u.params().append({"filter", *filter_key}); + if (filter_key) { + if (detail::ValidateFilterKey(*filter_key)) { + u.params().append({"filter", *filter_key}); + } else { + LD_LOG(logger, LogLevel::kError) + << "data source config: provided filter is invalid, will " + "request full environment instead"; + } } return {std::string(u.buffer()), network::HttpMethod::kGet, builder.Build(), diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp index 086d3c8db..047a4788d 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp @@ -19,7 +19,8 @@ network::HttpRequest MakeFDv2PollRequest( config::built::ServiceEndpoints const& endpoints, config::built::HttpProperties const& http_properties, data_model::Selector const& selector, - std::optional const& filter_key); + std::optional const& filter_key, + Logger const& logger); // Parse an HTTP response from the FDv2 polling endpoint through the protocol // handler and return the appropriate result. identity is used in log messages diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index 05620161d..2f07d95b2 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -20,7 +20,8 @@ FDv2PollingInitializer::FDv2PollingInitializer( : request_(MakeFDv2PollRequest(endpoints, http_properties, std::move(selector), - std::move(filter_key))), + std::move(filter_key), + logger)), requester_(executor, http_properties.Tls()), state_(std::make_shared(logger)) {} diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 84df5e0f3..5fce4c07a 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -34,7 +34,7 @@ FDv2PollingSynchronizer::State::State( async::Future FDv2PollingSynchronizer::State::Request( data_model::Selector const& selector) const { auto request = MakeFDv2PollRequest(endpoints_, http_properties_, selector, - filter_key_); + filter_key_, logger_); // Promise must be in a shared_ptr because Requester requires callbacks // to be copy-constructible (stored in std::function). diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp index e6bc0dceb..c43483fc3 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp @@ -172,8 +172,14 @@ void FDv2StreamingSynchronizer::State::OnConnect(HttpRequest* req) { if (latest_selector_.value) { u.params().set("basis", latest_selector_.value->state); } - if (filter_key_ && detail::ValidateFilterKey(*filter_key_)) { - u.params().set("filter", *filter_key_); + if (filter_key_) { + if (detail::ValidateFilterKey(*filter_key_)) { + u.params().set("filter", *filter_key_); + } else { + LD_LOG(logger_, LogLevel::kError) + << "data source config: provided filter is invalid, will " + "request full environment instead"; + } } req->target(u.encoded_target()); } diff --git a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp index 99cd536f9..c9ed53da5 100644 --- a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp +++ b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp @@ -110,41 +110,45 @@ TEST(HandleFDv2PollResponseTest, NetworkErrorDoesNotSetFlag) { } TEST(MakeFDv2PollRequestTest, BaseWithTrailingSlashDoesNotProduceDoubleSlash) { + auto logger = MakeNullLogger(); config::shared::built::ServiceEndpoints endpoints{"http://example.com/", "", ""}; auto props = config::shared::Defaults::HttpProperties(); auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, - std::nullopt); + std::nullopt, logger); EXPECT_EQ(req.Url(), "http://example.com/sdk/poll"); } TEST(MakeFDv2PollRequestTest, BaseWithSubpathTrailingSlashJoinsCleanly) { + auto logger = MakeNullLogger(); config::shared::built::ServiceEndpoints endpoints{ "http://example.com/relay/", "", ""}; auto props = config::shared::Defaults::HttpProperties(); auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, - std::nullopt); + std::nullopt, logger); EXPECT_EQ(req.Url(), "http://example.com/relay/sdk/poll"); } TEST(MakeFDv2PollRequestTest, ValidFilterKeyIsIncluded) { + auto logger = MakeNullLogger(); config::shared::built::ServiceEndpoints endpoints{"http://example.com", "", ""}; auto props = config::shared::Defaults::HttpProperties(); auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, - std::string{"my-filter_1.0"}); + std::string{"my-filter_1.0"}, logger); EXPECT_EQ(req.Url(), "http://example.com/sdk/poll?filter=my-filter_1.0"); } TEST(MakeFDv2PollRequestTest, InvalidFilterKeyIsDropped) { + auto logger = MakeNullLogger(); config::shared::built::ServiceEndpoints endpoints{"http://example.com", "", ""}; auto props = config::shared::Defaults::HttpProperties(); auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, - std::string{"has spaces"}); + std::string{"has spaces"}, logger); EXPECT_EQ(req.Url(), "http://example.com/sdk/poll"); }