diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index ff2d7d75d..ea5e198a4 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -54,6 +54,7 @@ target_sources(${LIBNAME} data_components/big_segments/big_segment_store_status_provider.hpp data_components/big_segments/big_segment_store_status_provider.cpp data_interfaces/destination/itransactional_destination.hpp + data_interfaces/source/fdv2_source_result.cpp data_components/memory_store/memory_store.hpp data_components/memory_store/memory_store.cpp data_components/serialization_adapters/json_deserializer.hpp diff --git a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.cpp b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.cpp new file mode 100644 index 000000000..dd7198350 --- /dev/null +++ b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.cpp @@ -0,0 +1,47 @@ +#include "fdv2_source_result.hpp" + +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_interfaces { + +namespace { + +// Subtracts a value drawn uniformly from [0, ttl/2], so that SDKs which fell +// back within the same window do not all re-attempt FDv2 at once. +std::chrono::seconds Jitter(std::chrono::seconds ttl) { + static thread_local std::mt19937_64 generator{std::random_device{}()}; + std::uniform_int_distribution distribution( + 0, ttl.count() / 2); + return ttl - std::chrono::seconds(distribution(generator)); +} + +} // namespace + +FDv1FallbackDirective FDv1FallbackDirective::DefaultTtl() { + return FDv1FallbackDirective{Jitter(kDefaultTtl)}; +} + +FDv1FallbackDirective FDv1FallbackDirective::FromServiceTtl( + std::chrono::seconds ttl) { + if (ttl > std::chrono::seconds::zero() && ttl <= kDefaultTtl) { + return FDv1FallbackDirective{ttl}; + } + return DefaultTtl(); +} + +FDv1FallbackDirective FDv1FallbackDirective::FromServiceTtl( + std::string_view ttl) { + std::uint64_t seconds = 0; + auto const* begin = ttl.data(); + auto const* end = begin + ttl.size(); + auto const [ptr, ec] = std::from_chars(begin, end, seconds); + if (ec != std::errc{} || ptr != end) { + return DefaultTtl(); + } + return FromServiceTtl(std::chrono::seconds(seconds)); +} + +} // namespace launchdarkly::server_side::data_interfaces diff --git a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp index 53a0cd442..aa97d6052 100644 --- a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp +++ b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp @@ -5,13 +5,10 @@ #include #include -#include #include -#include #include #include #include -#include #include namespace launchdarkly::server_side::data_interfaces { @@ -22,32 +19,32 @@ namespace launchdarkly::server_side::data_interfaces { * X-LD-FD-Fallback: true response header). */ struct FDv1FallbackDirective { - /** Default TTL used when the directive carries no TTL of its own. */ + /** Used whenever the service supplies no usable TTL. */ static constexpr std::chrono::seconds kDefaultTtl = std::chrono::hours(1); /** - * Parse the value of an X-LD-FD-Fallback-TTL header (or equivalent - * protocolFallbackTTL field from a goodbye message). Returns - * std::nullopt if the value is malformed. + * Builds a directive using the jittered default TTL, so that SDKs which + * fell back together do not all retry at once. Used when the service + * supplies no usable TTL. Safe to call from any thread. */ - static std::optional ParseTtl( - std::string_view value) { - std::uint64_t seconds = 0; - auto const* begin = value.data(); - auto const* end = begin + value.size(); - auto const [ptr, ec] = std::from_chars(begin, end, seconds); - if (ec != std::errc{} || ptr != end) { - return std::nullopt; - } - return std::chrono::seconds(seconds); - } + static FDv1FallbackDirective DefaultTtl(); /** - * How long to stay on FDv1 before attempting to recover to FDv2. - * A value of 0 seconds means the SDK should remain on FDv1 - * indefinitely. + * Builds a directive from a service-supplied TTL, used as given. A value + * outside (0, 1 hour] uses the jittered default instead, so a fallback is + * never indefinite. */ - std::chrono::seconds ttl = kDefaultTtl; + static FDv1FallbackDirective FromServiceTtl(std::chrono::seconds ttl); + + /** + * Builds a directive from the raw value of an X-LD-FD-Fallback-TTL + * header. A value that is not a whole number of seconds uses the + * jittered default. + */ + static FDv1FallbackDirective FromServiceTtl(std::string_view ttl); + + /** How long to stay off FDv2 before attempting to recover to it. */ + std::chrono::seconds ttl; }; /** diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp index e5b3a52e0..e0e53c50a 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp @@ -441,9 +441,6 @@ void FDv2DataSystem::OnSynchronizerResult( } void FDv2DataSystem::ScheduleFDv2RetryLocked(std::chrono::seconds ttl) { - if (ttl == std::chrono::seconds::zero()) { - return; - } // Cancel any in-flight retry and start fresh; CancellationSource is // one-shot, so reusing the same source for successive schedules would // leak prior timers. diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.hpp index 55116c0d3..051f1b344 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.hpp @@ -258,7 +258,7 @@ class FDv2DataSystem final : public data_interfaces::IDataSystem { void OnConditionFired(IFDv2Condition::Type type); // Schedules an FDv2 recovery attempt after the given TTL. Called with - // mutex_ held. TTL of 0 disables the recovery and is a no-op. + // mutex_ held. void ScheduleFDv2RetryLocked(std::chrono::seconds ttl); // Invoked when the FDv1 fallback TTL expires. Switches the source list 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 0b668851b..6e223dc6a 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 @@ -41,16 +41,11 @@ ReadFDv1FallbackDirective(network::HttpResult::HeadersType const& headers) { if (it == headers.end() || !boost::iequals(it->second, "true")) { return std::nullopt; } - data_interfaces::FDv1FallbackDirective directive; auto const ttl_it = headers.find(kFDv1FallbackTtlHeader); - if (ttl_it != headers.end()) { - auto const ttl = - data_interfaces::FDv1FallbackDirective::ParseTtl(ttl_it->second); - if (ttl) { - directive.ttl = *ttl; - } - } - return directive; + return ttl_it == headers.end() + ? data_interfaces::FDv1FallbackDirective::DefaultTtl() + : data_interfaces::FDv1FallbackDirective::FromServiceTtl( + ttl_it->second); } network::HttpRequest MakeFDv2PollRequest( @@ -131,8 +126,9 @@ static FDv2SourceResult ParseFDv2PollEvents( if (auto* goodbye = std::get_if(&result)) { FDv2SourceResult result{FDv2SourceResult::Goodbye{goodbye->reason}}; if (goodbye->protocol_fallback_ttl) { - result.fdv1_fallback = data_interfaces::FDv1FallbackDirective{ - std::chrono::seconds(*goodbye->protocol_fallback_ttl)}; + result.fdv1_fallback = + data_interfaces::FDv1FallbackDirective::FromServiceTtl( + std::chrono::seconds(*goodbye->protocol_fallback_ttl)); } return result; } 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 0f09ef5cf..130c7a06a 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp @@ -192,16 +192,13 @@ void FDv2StreamingSynchronizer::State::OnResponse( latest_fdv1_fallback_.reset(); return; } - data_interfaces::FDv1FallbackDirective directive; auto const ttl_it = headers.find("X-LD-FD-Fallback-TTL"); - if (ttl_it != headers.end()) { - auto const value = ttl_it->value(); - auto const ttl = data_interfaces::FDv1FallbackDirective::ParseTtl( - std::string_view{value.data(), value.size()}); - if (ttl) { - directive.ttl = *ttl; - } - } + auto const directive = + ttl_it == headers.end() + ? data_interfaces::FDv1FallbackDirective::DefaultTtl() + : data_interfaces::FDv1FallbackDirective::FromServiceTtl( + std::string_view{ttl_it->value().data(), + ttl_it->value().size()}); std::lock_guard lock(mutex_); latest_fdv1_fallback_ = directive; } @@ -257,8 +254,8 @@ void FDv2StreamingSynchronizer::State::OnEvent(sse::Event const& event) { FDv2SourceResult::Goodbye{r.reason}}; if (r.protocol_fallback_ttl) { goodbye_result.fdv1_fallback = - data_interfaces::FDv1FallbackDirective{ - std::chrono::seconds(*r.protocol_fallback_ttl)}; + data_interfaces::FDv1FallbackDirective::FromServiceTtl( + std::chrono::seconds(*r.protocol_fallback_ttl)); } Notify(std::move(goodbye_result)); // Drop the current connection and reconnect; the protocol diff --git a/libs/server-sdk/tests/fdv2_data_system_test.cpp b/libs/server-sdk/tests/fdv2_data_system_test.cpp index e7d170ccd..d63cade60 100644 --- a/libs/server-sdk/tests/fdv2_data_system_test.cpp +++ b/libs/server-sdk/tests/fdv2_data_system_test.cpp @@ -37,6 +37,13 @@ Logger MakeNullLogger() { return Logger{std::make_shared()}; } +// Runs handlers until none are ready, without waiting on pending timers +// (e.g. the FDv2 recovery timer scheduled by an FDv1 fallback directive). +void RunReadyHandlers(boost::asio::io_context& ioc) { + while (ioc.poll() > 0) { + } +} + // Initializer that resolves Run() with a single pre-set result. class MockInitializer : public IFDv2Initializer { public: @@ -1097,8 +1104,7 @@ TEST(FDv2DataSystemTest, SynchronizerFdv1FlagSwitchesToFdv1Adapter) { data_model::ChangeSetType::kNone, {}, data_model::Selector{}}}}; - r.fdv1_fallback = - data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{1h}; return r; }()}); auto fdv2_factory = @@ -1120,7 +1126,7 @@ TEST(FDv2DataSystemTest, SynchronizerFdv1FlagSwitchesToFdv1Adapter) { /*recovery_condition_factory=*/nullptr, ioc.get_executor(), &status_manager, logger); ds.Initialize(); - ioc.run(); + RunReadyHandlers(ioc); EXPECT_EQ(1, fdv1_factory_ptr->build_count_); } @@ -1131,7 +1137,6 @@ TEST(FDv2DataSystemTest, boost::asio::io_context ioc; data_components::DataSourceStatusManager status_manager; - // Directive with TTL=0: indefinite, no automatic FDv2 retry. auto fdv2_sync = std::make_unique(std::vector{[]() { FDv2SourceResult r{ @@ -1139,8 +1144,7 @@ TEST(FDv2DataSystemTest, FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "directive", std::chrono::system_clock::now()}}}; - r.fdv1_fallback = - data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{1h}; return r; }()}); auto fdv2_factory = @@ -1154,12 +1158,55 @@ TEST(FDv2DataSystemTest, /*recovery_condition_factory=*/nullptr, ioc.get_executor(), &status_manager, logger); ds.Initialize(); - ioc.run(); + RunReadyHandlers(ioc); EXPECT_NE(DataSourceStatus::DataSourceState::kOff, status_manager.Status().State()); } +TEST(FDv2DataSystemTest, + SynchronizerFdv1FlagWithoutAdapterStillRecoversToFDv2AfterTtl) { + auto logger = MakeNullLogger(); + boost::asio::io_context ioc; + data_components::DataSourceStatusManager status_manager; + + // First build: emits the directive with a 1s TTL; no FDv1 fallback + // synchronizer is configured. Second build (after the TTL elapses): + // returns no results, so MockSynchronizer emits Shutdown. + auto first_sync = + std::make_unique(std::vector{[]() { + FDv2SourceResult r{ + FDv2SourceResult::Interrupted{FDv2SourceResult::ErrorInfo{ + FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, + /*status_code=*/418, "directive", + std::chrono::system_clock::now()}}}; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{1s}; + return r; + }()}); + auto second_sync = + std::make_unique(std::vector{}); + + std::vector> sources; + sources.push_back(std::move(first_sync)); + sources.push_back(std::move(second_sync)); + auto factory = + std::make_unique(std::move(sources)); + auto* factory_ptr = factory.get(); + + std::vector> synchronizers; + synchronizers.push_back(std::move(factory)); + + FDv2DataSystem ds({}, std::move(synchronizers), + /*fallback_condition_factory=*/nullptr, + /*recovery_condition_factory=*/nullptr, + ioc.get_executor(), &status_manager, logger); + ds.Initialize(); + ioc.run(); + + // The fallback was not indefinite: FDv2 was rebuilt after the TTL. + EXPECT_EQ(2, factory_ptr->build_count_); +} + TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { auto logger = MakeNullLogger(); boost::asio::io_context ioc; @@ -1171,8 +1218,7 @@ TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "directive", std::chrono::system_clock::now()}}}; - init_result.fdv1_fallback = - data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; + init_result.fdv1_fallback = data_interfaces::FDv1FallbackDirective{1h}; auto initializer = std::make_unique(std::move(init_result)); @@ -1201,7 +1247,7 @@ TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { /*recovery_condition_factory=*/nullptr, ioc.get_executor(), &status_manager, logger); ds.Initialize(); - ioc.run(); + RunReadyHandlers(ioc); // FDv2 synchronizer was skipped; FDv1 adapter was built and ran. EXPECT_EQ(0, fdv2_factory_ptr->build_count_); @@ -1226,8 +1272,7 @@ TEST(FDv2DataSystemTest, ItemChange{"flagA", data_model::FlagDescriptor(flag_a)}, }, MakeSelector(1, "state-1")); - init_result.fdv1_fallback = - data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; + init_result.fdv1_fallback = data_interfaces::FDv1FallbackDirective{1h}; auto initializer = std::make_unique(std::move(init_result)); @@ -1257,7 +1302,7 @@ TEST(FDv2DataSystemTest, /*recovery_condition_factory=*/nullptr, ioc.get_executor(), &status_manager, logger); ds.Initialize(); - ioc.run(); + RunReadyHandlers(ioc); // Basis applied before the switch. EXPECT_TRUE(ds.Initialized()); @@ -1383,8 +1428,7 @@ TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { data_model::ChangeSetType::kNone, {}, data_model::Selector{}}}}; - r.fdv1_fallback = - data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{1h}; return r; }()}); auto fdv2_factory = @@ -1400,8 +1444,7 @@ TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "self-trigger", std::chrono::system_clock::now()}}}; - r.fdv1_fallback = - data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{1h}; return r; }()}); auto fdv1_factory = @@ -1417,7 +1460,7 @@ TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { /*recovery_condition_factory=*/nullptr, ioc.get_executor(), &status_manager, logger); ds.Initialize(); - ioc.run(); + RunReadyHandlers(ioc); EXPECT_EQ(1, fdv1_factory_ptr->build_count_); } diff --git a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp index d4ecd9ee6..587daeae5 100644 --- a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp +++ b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp @@ -7,9 +7,12 @@ #include #include +#include + using namespace launchdarkly; using namespace launchdarkly::server_side::data_interfaces; using namespace launchdarkly::server_side::data_systems; +using namespace std::chrono_literals; static Logger MakeNullLogger() { struct NullBackend : ILogBackend { @@ -102,7 +105,9 @@ TEST(HandleFDv2PollResponseTest, DirectiveWithoutTtlHeaderUsesDefault) { auto result = HandleResponse(304, std::nullopt, {{"X-LD-FD-Fallback", "true"}}); ASSERT_TRUE(result.fdv1_fallback); - EXPECT_EQ(FDv1FallbackDirective::kDefaultTtl, result.fdv1_fallback->ttl); + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(result.fdv1_fallback->ttl, 30min); + EXPECT_LE(result.fdv1_fallback->ttl, 1h); } TEST(HandleFDv2PollResponseTest, DirectiveWithTtlHeaderParsesValue) { @@ -118,7 +123,22 @@ TEST(HandleFDv2PollResponseTest, DirectiveWithMalformedTtlFallsBackToDefault) { {{"X-LD-FD-Fallback", "true"}, {"X-LD-FD-Fallback-TTL", "not-a-number"}}); ASSERT_TRUE(result.fdv1_fallback); - EXPECT_EQ(FDv1FallbackDirective::kDefaultTtl, result.fdv1_fallback->ttl); + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(result.fdv1_fallback->ttl, 30min); + EXPECT_LE(result.fdv1_fallback->ttl, 1h); +} + +TEST(HandleFDv2PollResponseTest, DirectiveWithOutOfRangeTtlUsesDefault) { + for (auto const* ttl : {"0", "3601", "86400"}) { + auto result = HandleResponse( + 304, std::nullopt, + {{"X-LD-FD-Fallback", "true"}, {"X-LD-FD-Fallback-TTL", ttl}}); + ASSERT_TRUE(result.fdv1_fallback); + + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(result.fdv1_fallback->ttl, 30min); + EXPECT_LE(result.fdv1_fallback->ttl, 1h); + } } TEST(HandleFDv2PollResponseTest, NetworkErrorDoesNotSetFlag) { diff --git a/libs/server-sdk/tests/fdv2_source_result_test.cpp b/libs/server-sdk/tests/fdv2_source_result_test.cpp new file mode 100644 index 000000000..9c8301fd4 --- /dev/null +++ b/libs/server-sdk/tests/fdv2_source_result_test.cpp @@ -0,0 +1,57 @@ +#include + +#include + +#include +#include + +using namespace launchdarkly::server_side::data_interfaces; +using namespace std::chrono_literals; + +TEST(FDv1FallbackDirectiveTests, UsesAServiceSuppliedTtlAsGiven) { + // The service jitters the TTLs it supplies, so the SDK must not. + for (auto ttl : {1s, 60s, 3599s, 3600s}) { + EXPECT_EQ(ttl, FDv1FallbackDirective::FromServiceTtl(ttl).ttl); + } +} + +TEST(FDv1FallbackDirectiveTests, JittersTheDefaultWhenNoTtlIsSupplied) { + auto const directive = FDv1FallbackDirective::DefaultTtl(); + + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(directive.ttl, 30min); + EXPECT_LE(directive.ttl, 1h); +} + +TEST(FDv1FallbackDirectiveTests, FallsBackToTheDefaultForOutOfRangeTtls) { + for (auto ttl : {0s, -60s, 3601s, std::chrono::seconds{24h * 7}}) { + auto const directive = FDv1FallbackDirective::FromServiceTtl(ttl); + + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(directive.ttl, 30min); + EXPECT_LE(directive.ttl, 1h); + } +} + +TEST(FDv1FallbackDirectiveTests, ParsesATtlHeaderValue) { + EXPECT_EQ(120s, FDv1FallbackDirective::FromServiceTtl("120").ttl); +} + +TEST(FDv1FallbackDirectiveTests, TreatsAMalformedTtlHeaderAsAbsent) { + for (auto const* value : {"", "abc", "12.5", "60s", "-60", " 60", "0"}) { + auto const directive = FDv1FallbackDirective::FromServiceTtl(value); + + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(directive.ttl, 30min); + EXPECT_LE(directive.ttl, 1h); + } +} + +TEST(FDv1FallbackDirectiveTests, DefaultTtlJitterVaries) { + std::set observed; + for (int i = 0; i < 50; i++) { + observed.insert(FDv1FallbackDirective::DefaultTtl().ttl); + } + + EXPECT_GT(observed.size(), 1u); +} diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index 7fd28185d..802241012 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -433,6 +433,52 @@ TEST(FDv2StreamingSynchronizerTest, GoodbyeEventReturnsGoodbye) { EXPECT_EQ(*g->reason, "bye"); } +TEST(FDv2StreamingSynchronizerTest, GoodbyeFallbackTtlIsUsedAsGiven) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); + FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); + + sse::Event goodbye("goodbye", + R"({"reason":"bye","protocolFallbackTTL":120})"); + FDv2StreamingSynchronizerTestPeer::OnEvent(synchronizer, goodbye); + auto result = synchronizer.Next(data_model::Selector{}).WaitForResult(2s); + + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->fdv1_fallback); + EXPECT_EQ(120s, result->fdv1_fallback->ttl); +} + +TEST(FDv2StreamingSynchronizerTest, GoodbyeOutOfRangeFallbackTtlUsesDefault) { + for (auto const* ttl : {"0", "-60", "3601"}) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); + FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); + + sse::Event goodbye( + "goodbye", + std::string(R"({"reason":"bye","protocolFallbackTTL":)") + ttl + + "}"); + FDv2StreamingSynchronizerTestPeer::OnEvent(synchronizer, goodbye); + auto result = + synchronizer.Next(data_model::Selector{}).WaitForResult(2s); + + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->fdv1_fallback); + + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(result->fdv1_fallback->ttl, 30min); + EXPECT_LE(result->fdv1_fallback->ttl, 1h); + } +} + TEST(FDv2StreamingSynchronizerTest, GoodbyeEventTriggersAsyncRestart) { auto logger = MakeNullLogger(); IoContextRunner runner; @@ -877,5 +923,7 @@ TEST(FDv2StreamingSynchronizerTest, DirectiveWithoutTtlHeaderUsesDefault) { // The result carries the default TTL. ASSERT_TRUE(result.has_value()); ASSERT_TRUE(result->fdv1_fallback); - EXPECT_EQ(FDv1FallbackDirective::kDefaultTtl, result->fdv1_fallback->ttl); + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(result->fdv1_fallback->ttl, 30min); + EXPECT_LE(result->fdv1_fallback->ttl, 1h); }