From 9b53de77d85c45b9de3a9e2f7cf0cef2672ba7a2 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 13 May 2026 15:49:20 -0400 Subject: [PATCH 1/9] test: Cover FDv2 request shapes in instance-id and tags tests CommonInstanceIDTests and CommonTagsTests previously asserted the X-LaunchDarkly-Instance-Id and X-LaunchDarkly-Tags headers only on a single streaming synchronizer, a single polling synchronizer, and the events endpoint. FDv2 introduces three new request shapes that the suite did not exercise: polling Initializers that run before the synchronizer, Secondary Synchronizers reached after the Primary is permanently removed, and the FDv1 Fallback Synchronizer reached via the server-directed FDv1 Fallback Directive. Add subtests in both files that stand up each new request shape and assert the relevant header is present. The new subtests are server-side only, since the FDv2 features they exercise are server-side today; the FDv1 directive subtest is additionally gated on CapabilityFDv1Fallback so SDKs that have not yet implemented the directive can opt out. The tags variant uses one representative tags config for the new subtests rather than iterating over makeValidTagsTestParams -- endpoint coverage and tag-value variation are orthogonal properties. --- sdktests/common_tests_instance_id.go | 87 +++++++++++++++++++++++ sdktests/common_tests_tags.go | 101 +++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index 63667ee..ac22475 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -1,13 +1,17 @@ package sdktests import ( + "net/http" "time" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldtime" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-test-helpers/v2/httphelpers" "github.com/launchdarkly/sdk-test-harness/v2/framework/harness" "github.com/launchdarkly/sdk-test-harness/v2/framework/ldtest" o "github.com/launchdarkly/sdk-test-harness/v2/framework/opt" + "github.com/launchdarkly/sdk-test-harness/v2/mockld" "github.com/launchdarkly/sdk-test-harness/v2/servicedef" "github.com/stretchr/testify/assert" @@ -62,6 +66,89 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { verifyRequestHeader(t, events.Endpoint()) }) + + // FDv2 introduces request shapes that are not exercised by the streaming + // or polling synchronizer subtests above: an Initializer request that + // precedes the synchronizer, a Secondary Synchronizer that is only + // contacted after the Primary is permanently removed, and an FDv1 Fallback + // Synchronizer reached via the server-directed FDv1 Fallback Directive. + // These shapes are server-side only today; gate accordingly. + if !c.isClientSide { + t.Run("polling initializer requests", func(t *ldtest.T) { + initializerData := mockld.NewServerSDKDataBuilder().Build() + synchronizerData := mockld.NewServerSDKDataBuilder(). + IntentCode("none").IntentReason("up-to-date").Build() + dataSystem := NewSDKDataSystem(t, synchronizerData, + DataSystemOptionPollingInitializer(initializerData)) + _ = NewSDKClient(t, c.baseSDKConfigurationPlus(dataSystem)...) + verifyRequestHeader(t, dataSystem.Initializers[0].Endpoint()) + }) + + t.Run("secondary synchronizer requests after permanent fallback", func(t *ldtest.T) { + // Primary returns 401, a non-recoverable status that permanently + // removes it from the synchronizer chain and causes the SDK to + // fall through to the Secondary immediately. + primaryEndpoint := requireContext(t).harness.NewMockEndpoint( + httphelpers.HandlerWithStatus(401), t.DebugLogger(), + harness.MockEndpointDescription("unauthorized primary streaming service")) + t.Defer(primaryEndpoint.Close) + + secondaryStream := mockld.NewStreamingService( + mockld.NewServerSDKDataBuilder().Build(), + requireContext(t).sdkKind, t.DebugLogger()) + secondaryEndpoint := requireContext(t).harness.NewMockEndpoint( + secondaryStream, t.DebugLogger(), + harness.MockEndpointDescription("secondary streaming service")) + t.Defer(secondaryEndpoint.Close) + + _ = NewSDKClient(t, c.baseSDKConfigurationPlus( + WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ + BaseURI: primaryEndpoint.BaseURL(), + }), + WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ + BaseURI: secondaryEndpoint.BaseURL(), + }))...) + + verifyRequestHeader(t, secondaryEndpoint) + }) + } + + if t.Capabilities().Has(servicedef.CapabilityFDv1Fallback) { + t.Run("FDv1 fallback directive requests", func(t *ldtest.T) { + // FDv2 streaming responds with 403 + directive on every request + // so the SDK transitions to the FDv1 Fallback Synchronizer. The + // FDv1 endpoint serves an empty payload so initialization can + // complete along the fallback path. + streamEndpoint := requireContext(t).harness.NewMockEndpoint( + httphelpers.HandlerWithResponse( + 403, http.Header{"X-LD-FD-Fallback": []string{"true"}}, nil), + t.DebugLogger(), + harness.MockEndpointDescription("FDv2 streaming service (403 + directive)")) + t.Defer(streamEndpoint.Close) + + fdv1Endpoint := requireContext(t).harness.NewMockEndpoint( + httphelpers.HandlerWithResponse( + 200, + http.Header{"Content-Type": []string{"application/json"}}, + []byte(`{"flags":{},"segments":{}}`)), + t.DebugLogger(), + harness.MockEndpointDescription("FDv1 polling service")) + t.Defer(fdv1Endpoint.Close) + + _ = NewSDKClient(t, c.baseSDKConfigurationPlus( + WithConfig(servicedef.SDKConfigParams{ + StartWaitTimeMS: o.Some(ldtime.UnixMillisecondTime(5000)), + }), + WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ + BaseURI: streamEndpoint.BaseURL(), + }), + WithFDv1Fallback(servicedef.SDKConfigPollingParams{ + BaseURI: fdv1Endpoint.BaseURL(), + }))...) + + verifyRequestHeader(t, fdv1Endpoint) + }) + } } func (c CommonInstanceIDTests) RunPHP(t *ldtest.T) { diff --git a/sdktests/common_tests_tags.go b/sdktests/common_tests_tags.go index 373caac..810437f 100644 --- a/sdktests/common_tests_tags.go +++ b/sdktests/common_tests_tags.go @@ -1,15 +1,19 @@ package sdktests import ( + "net/http" "strings" "time" + "github.com/launchdarkly/go-sdk-common/v3/ldtime" "github.com/launchdarkly/sdk-test-harness/v2/framework/harness" h "github.com/launchdarkly/sdk-test-harness/v2/framework/helpers" "github.com/launchdarkly/sdk-test-harness/v2/framework/ldtest" o "github.com/launchdarkly/sdk-test-harness/v2/framework/opt" + "github.com/launchdarkly/sdk-test-harness/v2/mockld" "github.com/launchdarkly/sdk-test-harness/v2/servicedef" + "github.com/launchdarkly/go-test-helpers/v2/httphelpers" "github.com/launchdarkly/go-test-helpers/v2/jsonhelpers" "github.com/stretchr/testify/assert" @@ -106,6 +110,103 @@ func (c CommonTagsTests) Run(t *ldtest.T) { } }) + // FDv2 introduces request shapes the streaming/polling synchronizer cases + // above do not cover: a polling Initializer that runs before the + // synchronizer, a Secondary Synchronizer reached after the Primary is + // permanently removed, and the FDv1 Fallback Synchronizer reached via the + // server-directed FDv1 Fallback Directive. The endpoint-coverage property + // for these new request shapes is orthogonal to the tag-value variation + // the subtests above exercise, so a single representative tags config is + // sufficient. + fdv2TagParams := tagsTestParams{ + tags: servicedef.SDKConfigTagsParams{ + ApplicationID: o.Some("test-app"), + ApplicationVersion: o.Some("1.0.0"), + }, + } + fdv2TagParams.expectedHeaderValue = c.makeExpectedTagsHeader(fdv2TagParams.tags) + + if !c.isClientSide { + t.Run("polling initializer requests", func(t *ldtest.T) { + initializerData := mockld.NewServerSDKDataBuilder().Build() + synchronizerData := mockld.NewServerSDKDataBuilder(). + IntentCode("none").IntentReason("up-to-date").Build() + dataSystem := NewSDKDataSystem(t, synchronizerData, + DataSystemOptionPollingInitializer(initializerData)) + _ = NewSDKClient(t, c.baseSDKConfigurationPlus( + withTagsConfig(fdv2TagParams.tags), + dataSystem)...) + verifyRequestHeader(t, fdv2TagParams, dataSystem.Initializers[0].Endpoint()) + }) + + t.Run("secondary synchronizer requests after permanent fallback", func(t *ldtest.T) { + // Primary returns 401, a non-recoverable status that permanently + // removes it from the synchronizer chain and causes the SDK to + // fall through to the Secondary immediately. + primaryEndpoint := requireContext(t).harness.NewMockEndpoint( + httphelpers.HandlerWithStatus(401), t.DebugLogger(), + harness.MockEndpointDescription("unauthorized primary streaming service")) + t.Defer(primaryEndpoint.Close) + + secondaryStream := mockld.NewStreamingService( + mockld.NewServerSDKDataBuilder().Build(), + requireContext(t).sdkKind, t.DebugLogger()) + secondaryEndpoint := requireContext(t).harness.NewMockEndpoint( + secondaryStream, t.DebugLogger(), + harness.MockEndpointDescription("secondary streaming service")) + t.Defer(secondaryEndpoint.Close) + + _ = NewSDKClient(t, c.baseSDKConfigurationPlus( + withTagsConfig(fdv2TagParams.tags), + WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ + BaseURI: primaryEndpoint.BaseURL(), + }), + WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ + BaseURI: secondaryEndpoint.BaseURL(), + }))...) + + verifyRequestHeader(t, fdv2TagParams, secondaryEndpoint) + }) + } + + if t.Capabilities().Has(servicedef.CapabilityFDv1Fallback) { + t.Run("FDv1 fallback directive requests", func(t *ldtest.T) { + // FDv2 streaming responds with 403 + directive on every request + // so the SDK transitions to the FDv1 Fallback Synchronizer. The + // FDv1 endpoint serves an empty payload so initialization can + // complete along the fallback path. + streamEndpoint := requireContext(t).harness.NewMockEndpoint( + httphelpers.HandlerWithResponse( + 403, http.Header{"X-LD-FD-Fallback": []string{"true"}}, nil), + t.DebugLogger(), + harness.MockEndpointDescription("FDv2 streaming service (403 + directive)")) + t.Defer(streamEndpoint.Close) + + fdv1Endpoint := requireContext(t).harness.NewMockEndpoint( + httphelpers.HandlerWithResponse( + 200, + http.Header{"Content-Type": []string{"application/json"}}, + []byte(`{"flags":{},"segments":{}}`)), + t.DebugLogger(), + harness.MockEndpointDescription("FDv1 polling service")) + t.Defer(fdv1Endpoint.Close) + + _ = NewSDKClient(t, c.baseSDKConfigurationPlus( + withTagsConfig(fdv2TagParams.tags), + WithConfig(servicedef.SDKConfigParams{ + StartWaitTimeMS: o.Some(ldtime.UnixMillisecondTime(5000)), + }), + WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ + BaseURI: streamEndpoint.BaseURL(), + }), + WithFDv1Fallback(servicedef.SDKConfigPollingParams{ + BaseURI: fdv1Endpoint.BaseURL(), + }))...) + + verifyRequestHeader(t, fdv2TagParams, fdv1Endpoint) + }) + } + runPermutations := func(t *ldtest.T, params []tagsTestParams) { for _, p := range params { // We're not using t.Run to make a subtest here because there would be so many. We'll From e127bf24270a5e75d944b767a57e9386349c7f78 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 15 May 2026 15:16:27 -0400 Subject: [PATCH 2/9] test: Verify X-LaunchDarkly-Instance-Id is stable across requests Each subtest in CommonInstanceIDTests previously asserted only that the header was non-empty. Two endpoints in the same subtest could return different non-empty values and the test would still pass, which would mask a regression where the SDK assigns a new instance-id per request shape (or, for example, uses a separate HTTP transport for events vs data and forgets to plumb the shared instance-id through). Replace the per-call non-empty check with a per-subtest checker that latches the first observed value and asserts every subsequent request carries the same value. The checker is scoped to one SDK client lifetime: each subtest constructs its own checker, since each subtest creates its own client and so legitimately receives a different instance-id. Where the SDK naturally contacts more than one endpoint in a subtest, check both: - event posts: the SDK hits the data source during init and the events endpoint on flush. - polling initializer requests: initializer runs before the synchronizer. - secondary synchronizer requests: primary receives the 401 before the SDK falls through to the secondary. - FDv1 fallback directive requests: FDv2 stream returns the 403 + directive before the SDK transitions to the FDv1 endpoint. Tags tests already verify consistency implicitly by comparing every endpoint against the same static expectedHeaderValue, so no change there. --- sdktests/common_tests_instance_id.go | 66 ++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index ac22475..fc290bb 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -29,11 +29,6 @@ func NewCommonInstanceIDTests(t *ldtest.T, testName string, baseSDKConfigurers . func (c CommonInstanceIDTests) Run(t *ldtest.T) { t.RequireCapability(servicedef.CapabilityInstanceID) - verifyRequestHeader := func(t *ldtest.T, endpoint *harness.MockEndpoint) { - request := endpoint.RequireConnection(t, time.Second) - assert.NotEmpty(t, request.Headers.Get("X-LaunchDarkly-Instance-Id")) - } - t.Run("stream requests", func(t *ldtest.T) { dataSystem := NewSDKDataSystem(t, nil, DataSystemOptionStreaming()) configurers := c.baseSDKConfigurationPlus(dataSystem) @@ -43,14 +38,16 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { NewSDKDataSystem(t, nil, DataSystemOptionPolling())) } _ = NewSDKClient(t, configurers...) - verifyRequestHeader(t, dataSystem.Synchronizers[0].Endpoint()) + check := newInstanceIDChecker(t) + check(dataSystem.Synchronizers[0].Endpoint()) }) if t.Capabilities().HasAny(servicedef.CapabilityClientSide, servicedef.CapabilityServerSidePolling) { t.Run("poll requests", func(t *ldtest.T) { dataSystem := NewSDKDataSystem(t, nil, DataSystemOptionPolling()) _ = NewSDKClient(t, c.baseSDKConfigurationPlus(dataSystem)...) - verifyRequestHeader(t, dataSystem.Synchronizers[0].Endpoint()) + check := newInstanceIDChecker(t) + check(dataSystem.Synchronizers[0].Endpoint()) }) } @@ -64,7 +61,12 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { c.sendArbitraryEvent(t, client) client.FlushEvents(t) - verifyRequestHeader(t, events.Endpoint()) + // The SDK contacts the data source during init and the events endpoint + // on flush; both must carry the same instance-id since they originate + // from the same client. + check := newInstanceIDChecker(t) + check(dataSystem.Synchronizers[0].Endpoint()) + check(events.Endpoint()) }) // FDv2 introduces request shapes that are not exercised by the streaming @@ -81,7 +83,9 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { dataSystem := NewSDKDataSystem(t, synchronizerData, DataSystemOptionPollingInitializer(initializerData)) _ = NewSDKClient(t, c.baseSDKConfigurationPlus(dataSystem)...) - verifyRequestHeader(t, dataSystem.Initializers[0].Endpoint()) + check := newInstanceIDChecker(t) + check(dataSystem.Initializers[0].Endpoint()) + check(dataSystem.Synchronizers[0].Endpoint()) }) t.Run("secondary synchronizer requests after permanent fallback", func(t *ldtest.T) { @@ -109,7 +113,9 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { BaseURI: secondaryEndpoint.BaseURL(), }))...) - verifyRequestHeader(t, secondaryEndpoint) + check := newInstanceIDChecker(t) + check(primaryEndpoint) + check(secondaryEndpoint) }) } @@ -146,18 +152,40 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { BaseURI: fdv1Endpoint.BaseURL(), }))...) - verifyRequestHeader(t, fdv1Endpoint) + check := newInstanceIDChecker(t) + check(streamEndpoint) + check(fdv1Endpoint) }) } } -func (c CommonInstanceIDTests) RunPHP(t *ldtest.T) { - t.RequireCapability(servicedef.CapabilityInstanceID) - - verifyRequestHeader := func(t *ldtest.T, endpoint *harness.MockEndpoint) { +// newInstanceIDChecker returns a function that asserts every observed request +// carries a non-empty X-LaunchDarkly-Instance-Id header AND that the value is +// identical across every endpoint observed by the returned checker. The +// instance-id identifies the SDK client instance, so it must be stable for the +// client's lifetime no matter which request shape carries it. Each subtest +// creates its own SDK client and so should create its own checker -- the +// latched value is per-client. +func newInstanceIDChecker(t *ldtest.T) func(*harness.MockEndpoint) { + var observed string + return func(endpoint *harness.MockEndpoint) { + t.Helper() request := endpoint.RequireConnection(t, time.Second) - assert.NotEmpty(t, request.Headers.Get("X-LaunchDarkly-Instance-Id")) + v := request.Headers.Get("X-LaunchDarkly-Instance-Id") + if !assert.NotEmpty(t, v, "X-LaunchDarkly-Instance-Id missing from request") { + return + } + if observed == "" { + observed = v + return + } + assert.Equal(t, observed, v, + "X-LaunchDarkly-Instance-Id differs across requests from the same SDK client") } +} + +func (c CommonInstanceIDTests) RunPHP(t *ldtest.T) { + t.RequireCapability(servicedef.CapabilityInstanceID) t.Run("poll requests", func(t *ldtest.T) { dataSystem := NewSDKDataSystem(t, nil) @@ -170,7 +198,8 @@ func (c CommonInstanceIDTests) RunPHP(t *ldtest.T) { Detail: false, }) - verifyRequestHeader(t, dataSystem.Synchronizers[0].Endpoint()) + check := newInstanceIDChecker(t) + check(dataSystem.Synchronizers[0].Endpoint()) }) t.Run("event posts", func(t *ldtest.T) { @@ -183,6 +212,7 @@ func (c CommonInstanceIDTests) RunPHP(t *ldtest.T) { c.sendArbitraryEvent(t, client) client.FlushEvents(t) - verifyRequestHeader(t, events.Endpoint()) + check := newInstanceIDChecker(t) + check(events.Endpoint()) }) } From 194247d0ec3a009970f45fba1d0c7bdb187d68dc Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 15 May 2026 15:23:25 -0400 Subject: [PATCH 3/9] test: Verify instance-id differs between client instances The earlier consistency check guarantees that within one SDK client all requests carry the same instance-id. The complementary property -- that two distinct clients in the same process produce different instance-ids -- was not covered. Without that check, an SDK that seeded the instance-id from process-level state and reused it across client instances would still pass every existing assertion, even though the resulting telemetry would be unable to distinguish the clients. Add a subtest that stands up two independent SDK clients back to back, captures the instance-id from each, and asserts the values differ. Gated on !CapabilitySingleton since the test creates a second client while the first still exists. --- sdktests/common_tests_instance_id.go | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index fc290bb..91027fd 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -69,6 +69,38 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { check(events.Endpoint()) }) + // instance-id identifies an SDK client instance; two distinct clients + // living in the same process must never share a value, or telemetry can't + // disambiguate them. Stand up two independent clients back to back and + // assert their instance-ids differ. Gated on !CapabilitySingleton since + // the test requires creating a second client while the first still exists. + if !t.Capabilities().Has(servicedef.CapabilitySingleton) { + t.Run("instance id differs between client instances", func(t *ldtest.T) { + captureInstanceID := func() string { + dataSystem := NewSDKDataSystem(t, nil, DataSystemOptionStreaming()) + configurers := c.baseSDKConfigurationPlus(dataSystem) + if c.isClientSide { + // client-side SDKs in streaming mode may *also* need a + // polling data source + configurers = append(configurers, + NewSDKDataSystem(t, nil, DataSystemOptionPolling())) + } + _ = NewSDKClient(t, configurers...) + request := dataSystem.Synchronizers[0].Endpoint().RequireConnection(t, time.Second) + v := request.Headers.Get("X-LaunchDarkly-Instance-Id") + assert.NotEmpty(t, v, "X-LaunchDarkly-Instance-Id missing from request") + return v + } + + first := captureInstanceID() + second := captureInstanceID() + + assert.NotEqual(t, first, second, + "two distinct SDK client instances must have distinct "+ + "X-LaunchDarkly-Instance-Id values") + }) + } + // FDv2 introduces request shapes that are not exercised by the streaming // or polling synchronizer subtests above: an Initializer request that // precedes the synchronizer, a Secondary Synchronizer that is only From 9e1133d44d35d440cdc58f8470c7d3cac9ae099c Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 15 May 2026 15:46:47 -0400 Subject: [PATCH 4/9] test: Fix WithConfig overwrite and check both endpoints in tags FDv2 subtests Multi-agent review caught a PROVEN bug in the tags FDv1 fallback subtest. The configurer list was withTagsConfig(fdv2TagParams.tags), WithConfig({StartWaitTimeMS: 5000}), WithStreamingSynchronizer(...), WithFDv1Fallback(...), WithConfig performs *configOut = config, which overwrites every field previously set -- including Tags. The SDK would have been started without tags configured, would not have sent X-LaunchDarkly-Tags on any request, and the assertion against the expected header value would have failed every run. Replace WithConfig with WithWaitToStart, which mutates only StartWaitTimeMS and InitCanFail. The same substitution is applied to the instance-id FDv1 fallback subtest; the bug was latent there (WithConfig was the first option in the chain), but the pattern is the same fragility waiting for a future option to be prepended. While in the area, bring tags FDv2 subtests up to instance-id parity by checking both endpoints exercised in each flow: - polling initializer: initializer + synchronizer - secondary synchronizer: primary + secondary - FDv1 fallback: FDv2 stream + FDv1 --- sdktests/common_tests_instance_id.go | 5 +---- sdktests/common_tests_tags.go | 8 ++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index 91027fd..1a644c8 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -5,7 +5,6 @@ import ( "time" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" - "github.com/launchdarkly/go-sdk-common/v3/ldtime" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" "github.com/launchdarkly/go-test-helpers/v2/httphelpers" "github.com/launchdarkly/sdk-test-harness/v2/framework/harness" @@ -174,9 +173,7 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { t.Defer(fdv1Endpoint.Close) _ = NewSDKClient(t, c.baseSDKConfigurationPlus( - WithConfig(servicedef.SDKConfigParams{ - StartWaitTimeMS: o.Some(ldtime.UnixMillisecondTime(5000)), - }), + WithWaitToStart(5*time.Second, false), WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ BaseURI: streamEndpoint.BaseURL(), }), diff --git a/sdktests/common_tests_tags.go b/sdktests/common_tests_tags.go index 810437f..593e2d9 100644 --- a/sdktests/common_tests_tags.go +++ b/sdktests/common_tests_tags.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/launchdarkly/go-sdk-common/v3/ldtime" "github.com/launchdarkly/sdk-test-harness/v2/framework/harness" h "github.com/launchdarkly/sdk-test-harness/v2/framework/helpers" "github.com/launchdarkly/sdk-test-harness/v2/framework/ldtest" @@ -137,6 +136,7 @@ func (c CommonTagsTests) Run(t *ldtest.T) { withTagsConfig(fdv2TagParams.tags), dataSystem)...) verifyRequestHeader(t, fdv2TagParams, dataSystem.Initializers[0].Endpoint()) + verifyRequestHeader(t, fdv2TagParams, dataSystem.Synchronizers[0].Endpoint()) }) t.Run("secondary synchronizer requests after permanent fallback", func(t *ldtest.T) { @@ -165,6 +165,7 @@ func (c CommonTagsTests) Run(t *ldtest.T) { BaseURI: secondaryEndpoint.BaseURL(), }))...) + verifyRequestHeader(t, fdv2TagParams, primaryEndpoint) verifyRequestHeader(t, fdv2TagParams, secondaryEndpoint) }) } @@ -193,9 +194,7 @@ func (c CommonTagsTests) Run(t *ldtest.T) { _ = NewSDKClient(t, c.baseSDKConfigurationPlus( withTagsConfig(fdv2TagParams.tags), - WithConfig(servicedef.SDKConfigParams{ - StartWaitTimeMS: o.Some(ldtime.UnixMillisecondTime(5000)), - }), + WithWaitToStart(5*time.Second, false), WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ BaseURI: streamEndpoint.BaseURL(), }), @@ -203,6 +202,7 @@ func (c CommonTagsTests) Run(t *ldtest.T) { BaseURI: fdv1Endpoint.BaseURL(), }))...) + verifyRequestHeader(t, fdv2TagParams, streamEndpoint) verifyRequestHeader(t, fdv2TagParams, fdv1Endpoint) }) } From a7b0a6b08a92b41dddc5ee6f0307b45dd89fb8a5 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 1 Jun 2026 12:16:36 -0400 Subject: [PATCH 5/9] test: Assert abandoned source is disconnected in FDv2 fallback subtests Address Cursor Bugbot feedback on the new FDv2 header-coverage subtests: the secondary-synchronizer and FDv1-fallback subtests verified headers on both endpoints but never asserted the abandoned source stops receiving connections. An SDK that falls through to the secondary/FDv1 path but keeps retrying the removed primary/FDv2 stream in the background would have passed. Add RequireNoMoreConnections on the abandoned endpoint at the end of all four subtests, matching the pattern in DirectiveOnStreamingErrorEngagesFDv1. --- sdktests/common_tests_instance_id.go | 9 +++++++++ sdktests/common_tests_tags.go | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index 1a644c8..ead267e 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -147,6 +147,11 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { check := newInstanceIDChecker(t) check(primaryEndpoint) check(secondaryEndpoint) + + // The Primary was permanently removed on the non-recoverable 401; + // assert the SDK is not still retrying it in the background after + // falling through to the Secondary. + primaryEndpoint.RequireNoMoreConnections(t, time.Millisecond*500) }) } @@ -184,6 +189,10 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { check := newInstanceIDChecker(t) check(streamEndpoint) check(fdv1Endpoint) + + // Once the directive engaged the FDv1 fallback, the FDv2 stream must + // be quiet; assert the SDK is not concurrently retrying it. + streamEndpoint.RequireNoMoreConnections(t, time.Millisecond*500) }) } } diff --git a/sdktests/common_tests_tags.go b/sdktests/common_tests_tags.go index 593e2d9..40506f6 100644 --- a/sdktests/common_tests_tags.go +++ b/sdktests/common_tests_tags.go @@ -167,6 +167,11 @@ func (c CommonTagsTests) Run(t *ldtest.T) { verifyRequestHeader(t, fdv2TagParams, primaryEndpoint) verifyRequestHeader(t, fdv2TagParams, secondaryEndpoint) + + // The Primary was permanently removed on the non-recoverable 401; + // assert the SDK is not still retrying it in the background after + // falling through to the Secondary. + primaryEndpoint.RequireNoMoreConnections(t, time.Millisecond*500) }) } @@ -204,6 +209,10 @@ func (c CommonTagsTests) Run(t *ldtest.T) { verifyRequestHeader(t, fdv2TagParams, streamEndpoint) verifyRequestHeader(t, fdv2TagParams, fdv1Endpoint) + + // Once the directive engaged the FDv1 fallback, the FDv2 stream must + // be quiet; assert the SDK is not concurrently retrying it. + streamEndpoint.RequireNoMoreConnections(t, time.Millisecond*500) }) } From 538e98c262a8f3e24a8254da4f43b07428361daf Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 1 Jun 2026 12:27:18 -0400 Subject: [PATCH 6/9] test: Gate FDv2 subtests on positive server-side check Replace the negative `!c.isClientSide` guard on the FDv2 request-shape subtests with the positive `c.sdkKind.IsServerSide()`. Functionally identical today (IsClientSide is defined as !IsServerSide), but states intent directly and means a future SDK category must opt into these server-side-only shapes explicitly rather than inheriting them by default. Addresses review feedback from tanderson-ld and Cursor Bugbot. There is no FDv2/data-system capability to gate on; FDv2 support is assumed for all server-side SDKs, matching how doServerSideFDv2StreamTests is gated. --- sdktests/common_tests_instance_id.go | 7 +++++-- sdktests/common_tests_tags.go | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index ead267e..f4620bf 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -105,8 +105,11 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { // precedes the synchronizer, a Secondary Synchronizer that is only // contacted after the Primary is permanently removed, and an FDv1 Fallback // Synchronizer reached via the server-directed FDv1 Fallback Directive. - // These shapes are server-side only today; gate accordingly. - if !c.isClientSide { + // These shapes are server-side only today, so gate on a positive + // identification of the server-side category rather than "not client-side": + // a future SDK category should have to opt in explicitly, not inherit these + // by default. + if c.sdkKind.IsServerSide() { t.Run("polling initializer requests", func(t *ldtest.T) { initializerData := mockld.NewServerSDKDataBuilder().Build() synchronizerData := mockld.NewServerSDKDataBuilder(). diff --git a/sdktests/common_tests_tags.go b/sdktests/common_tests_tags.go index 40506f6..c74fa22 100644 --- a/sdktests/common_tests_tags.go +++ b/sdktests/common_tests_tags.go @@ -125,7 +125,11 @@ func (c CommonTagsTests) Run(t *ldtest.T) { } fdv2TagParams.expectedHeaderValue = c.makeExpectedTagsHeader(fdv2TagParams.tags) - if !c.isClientSide { + // These shapes are server-side only today, so gate on a positive + // identification of the server-side category rather than "not client-side": + // a future SDK category should have to opt in explicitly, not inherit these + // by default. + if c.sdkKind.IsServerSide() { t.Run("polling initializer requests", func(t *ldtest.T) { initializerData := mockld.NewServerSDKDataBuilder().Build() synchronizerData := mockld.NewServerSDKDataBuilder(). From 3eecf7d03438011f3ae1824a543f27e9a0409f14 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 1 Jun 2026 13:04:50 -0400 Subject: [PATCH 7/9] test: Port FDv2 header subtests to post-#325 data API feat/fdv2 (#325) reworked the mock data model: ServerSDKDataBuilder no longer carries IntentCode/IntentReason, and the streaming/polling mock services now require FDv2-format data instead of auto-converting plain ServerSDKData. After merging the updated base, port the new subtests: - Build the no-op synchronizer via FDv2SDKDataFromServerSDKData(..., "none", "up-to-date", "initial") instead of the removed builder methods. - Serve the secondary streaming synchronizer a valid FDv2 "xfer-full" basis so the SDK can initialize from it; raw ServerSDKData now makes the mock streamer reject the request ("cannot handle non-fdv2 sdk data") and init times out. Verified against go-server-sdk (v7) and ruby-server-sdk (main) contract test services: all instance-id and tags subtests pass. --- sdktests/common_tests_instance_id.go | 9 +++++---- sdktests/common_tests_tags.go | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index f4620bf..8af65fc 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -112,8 +112,8 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { if c.sdkKind.IsServerSide() { t.Run("polling initializer requests", func(t *ldtest.T) { initializerData := mockld.NewServerSDKDataBuilder().Build() - synchronizerData := mockld.NewServerSDKDataBuilder(). - IntentCode("none").IntentReason("up-to-date").Build() + synchronizerData := mockld.FDv2SDKDataFromServerSDKData( + mockld.NewServerSDKDataBuilder().Build(), "none", "up-to-date", "initial") dataSystem := NewSDKDataSystem(t, synchronizerData, DataSystemOptionPollingInitializer(initializerData)) _ = NewSDKClient(t, c.baseSDKConfigurationPlus(dataSystem)...) @@ -131,9 +131,10 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { harness.MockEndpointDescription("unauthorized primary streaming service")) t.Defer(primaryEndpoint.Close) + secondaryData := mockld.FDv2SDKDataFromServerSDKData( + mockld.NewServerSDKDataBuilder().Build(), "xfer-full", "initial", "initial") secondaryStream := mockld.NewStreamingService( - mockld.NewServerSDKDataBuilder().Build(), - requireContext(t).sdkKind, t.DebugLogger()) + secondaryData, requireContext(t).sdkKind, t.DebugLogger()) secondaryEndpoint := requireContext(t).harness.NewMockEndpoint( secondaryStream, t.DebugLogger(), harness.MockEndpointDescription("secondary streaming service")) diff --git a/sdktests/common_tests_tags.go b/sdktests/common_tests_tags.go index 964d2de..772994d 100644 --- a/sdktests/common_tests_tags.go +++ b/sdktests/common_tests_tags.go @@ -126,8 +126,8 @@ func (c CommonTagsTests) Run(t *ldtest.T) { if c.sdkKind.IsServerSide() { t.Run("polling initializer requests", func(t *ldtest.T) { initializerData := mockld.NewServerSDKDataBuilder().Build() - synchronizerData := mockld.NewServerSDKDataBuilder(). - IntentCode("none").IntentReason("up-to-date").Build() + synchronizerData := mockld.FDv2SDKDataFromServerSDKData( + mockld.NewServerSDKDataBuilder().Build(), "none", "up-to-date", "initial") dataSystem := NewSDKDataSystem(t, synchronizerData, DataSystemOptionPollingInitializer(initializerData)) _ = NewSDKClient(t, c.baseSDKConfigurationPlus( @@ -146,9 +146,10 @@ func (c CommonTagsTests) Run(t *ldtest.T) { harness.MockEndpointDescription("unauthorized primary streaming service")) t.Defer(primaryEndpoint.Close) + secondaryData := mockld.FDv2SDKDataFromServerSDKData( + mockld.NewServerSDKDataBuilder().Build(), "xfer-full", "initial", "initial") secondaryStream := mockld.NewStreamingService( - mockld.NewServerSDKDataBuilder().Build(), - requireContext(t).sdkKind, t.DebugLogger()) + secondaryData, requireContext(t).sdkKind, t.DebugLogger()) secondaryEndpoint := requireContext(t).harness.NewMockEndpoint( secondaryStream, t.DebugLogger(), harness.MockEndpointDescription("secondary streaming service")) From 573150518526a55ea75720e2a038cb6572fe8aea Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 1 Jun 2026 13:14:11 -0400 Subject: [PATCH 8/9] test: Set service endpoints in FDv1 fallback directive subtests The existing FDv1 fallback tests in common_tests_stream_fdv2.go pair WithFDv1Fallback with WithServiceEndpoints so the top-level streaming and polling endpoints also point at the mocks. The new FDv1 fallback directive subtests omitted it, so an SDK that resolves the FDv1 polling URL from ServiceEndpoints.Polling (rather than DataSystem.FDv1Fallback.BaseURI) would contact the real LaunchDarkly endpoint and time out. Add WithServiceEndpoints to match the established convention. Addresses Cursor Bugbot feedback. Re-verified against go-server-sdk (v7) and ruby-server-sdk (main): all instance-id and tags subtests pass. --- sdktests/common_tests_instance_id.go | 9 +++++++++ sdktests/common_tests_tags.go | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index 8af65fc..70f0707 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -183,6 +183,15 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { _ = NewSDKClient(t, c.baseSDKConfigurationPlus( WithWaitToStart(5*time.Second, false), + // Point the top-level service endpoints at the mocks too: some + // SDKs resolve the FDv1 fallback polling URL from + // ServiceEndpoints.Polling rather than DataSystem.FDv1Fallback, + // and without this they would contact the real LaunchDarkly + // endpoint. Matches the existing FDv1 fallback tests. + WithServiceEndpoints(servicedef.SDKConfigServiceEndpointsParams{ + Streaming: streamEndpoint.BaseURL(), + Polling: fdv1Endpoint.BaseURL(), + }), WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ BaseURI: streamEndpoint.BaseURL(), }), diff --git a/sdktests/common_tests_tags.go b/sdktests/common_tests_tags.go index 772994d..eece772 100644 --- a/sdktests/common_tests_tags.go +++ b/sdktests/common_tests_tags.go @@ -199,6 +199,15 @@ func (c CommonTagsTests) Run(t *ldtest.T) { _ = NewSDKClient(t, c.baseSDKConfigurationPlus( withTagsConfig(fdv2TagParams.tags), WithWaitToStart(5*time.Second, false), + // Point the top-level service endpoints at the mocks too: some + // SDKs resolve the FDv1 fallback polling URL from + // ServiceEndpoints.Polling rather than DataSystem.FDv1Fallback, + // and without this they would contact the real LaunchDarkly + // endpoint. Matches the existing FDv1 fallback tests. + WithServiceEndpoints(servicedef.SDKConfigServiceEndpointsParams{ + Streaming: streamEndpoint.BaseURL(), + Polling: fdv1Endpoint.BaseURL(), + }), WithStreamingSynchronizer(servicedef.SDKConfigStreamingParams{ BaseURI: streamEndpoint.BaseURL(), }), From 48e0fd903b1cb3eb6263bdb048ba237e75c7b11b Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 1 Jun 2026 13:33:11 -0400 Subject: [PATCH 9/9] test: Serve FDv1 fallback body in the SDK kind's format The FDv1 fallback directive subtests are gated on CapabilityFDv1Fallback but not on server-side, yet they hardcoded the server-side FDv1 polling body ({"flags":..,"segments":..}). Client-side SDKs use a flat map of evaluations, so a client-side SDK claiming the capability would receive an unparseable payload and fail to initialize. Client-side support for the FDv1 Fallback Directive is in progress, so rather than gating these subtests to server-side only, add a commonTestsBase emptyFDv1FallbackBody helper that returns an empty payload in the format the SDK kind expects, and serve that. Keeps the capability-only gate valid for both kinds, matching the format-aware fdv1FallbackBody helper in common_tests_stream_fdv2.go. Addresses Cursor Bugbot feedback. Re-verified against go-server-sdk (v7) and ruby-server-sdk (main): all instance-id and tags subtests pass. --- sdktests/common_tests_base.go | 27 +++++++++++++++++++++++++++ sdktests/common_tests_instance_id.go | 2 +- sdktests/common_tests_tags.go | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/sdktests/common_tests_base.go b/sdktests/common_tests_base.go index d106980..b09fffd 100644 --- a/sdktests/common_tests_base.go +++ b/sdktests/common_tests_base.go @@ -1,6 +1,7 @@ package sdktests import ( + "encoding/json" "fmt" "regexp" @@ -236,6 +237,32 @@ func (c commonTestsBase) sendArbitraryEvent(t *ldtest.T, client *SDKClient) { client.SendCustomEvent(t, params) } +// emptyFDv1FallbackBody returns an empty FDv1 polling payload in the format the +// SDK kind expects: server-side SDKs receive a {"flags":..,"segments":..} object, +// while client-side SDKs receive a flat map of flag evaluations. The FDv1 fallback +// directive subtests only need initialization to complete (no flag values) so the +// request header can be asserted, so the payload is empty in either format. +// +// Client-side support for the FDv1 Fallback Directive is in progress; serving the +// correct format per kind keeps these subtests valid for both rather than feeding a +// client-side SDK an unparseable server-side body. +func (c commonTestsBase) emptyFDv1FallbackBody() []byte { + var body any + if c.isClientSide { + body = mockld.ClientSDKData{} + } else { + body = map[string]any{ + "flags": map[string]json.RawMessage{}, + "segments": map[string]json.RawMessage{}, + } + } + bytes, err := json.Marshal(body) + if err != nil { + panic(fmt.Errorf("failed to marshal empty FDv1 fallback body: %w", err)) + } + return bytes +} + func (c commonTestsBase) withHTTPProxy(url string) SDKConfigurer { return helpers.ConfigOptionFunc[servicedef.SDKConfigParams](func(configOut *servicedef.SDKConfigParams) error { configOut.Proxy = o.Some(servicedef.SDKConfigProxyParams{ diff --git a/sdktests/common_tests_instance_id.go b/sdktests/common_tests_instance_id.go index 70f0707..591106e 100644 --- a/sdktests/common_tests_instance_id.go +++ b/sdktests/common_tests_instance_id.go @@ -176,7 +176,7 @@ func (c CommonInstanceIDTests) Run(t *ldtest.T) { httphelpers.HandlerWithResponse( 200, http.Header{"Content-Type": []string{"application/json"}}, - []byte(`{"flags":{},"segments":{}}`)), + c.emptyFDv1FallbackBody()), t.DebugLogger(), harness.MockEndpointDescription("FDv1 polling service")) t.Defer(fdv1Endpoint.Close) diff --git a/sdktests/common_tests_tags.go b/sdktests/common_tests_tags.go index eece772..6e56f68 100644 --- a/sdktests/common_tests_tags.go +++ b/sdktests/common_tests_tags.go @@ -191,7 +191,7 @@ func (c CommonTagsTests) Run(t *ldtest.T) { httphelpers.HandlerWithResponse( 200, http.Header{"Content-Type": []string{"application/json"}}, - []byte(`{"flags":{},"segments":{}}`)), + c.emptyFDv1FallbackBody()), t.DebugLogger(), harness.MockEndpointDescription("FDv1 polling service")) t.Defer(fdv1Endpoint.Close)