Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions sdktests/common_tests_base.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sdktests

import (
"encoding/json"
"fmt"
"regexp"

Expand Down Expand Up @@ -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{
Expand Down
198 changes: 183 additions & 15 deletions sdktests/common_tests_instance_id.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package sdktests

import (
"net/http"
"time"

"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
"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"
Expand All @@ -25,11 +28,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)
Expand All @@ -39,14 +37,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())
})
}

Expand All @@ -60,17 +60,183 @@ 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())
})
}

func (c CommonInstanceIDTests) RunPHP(t *ldtest.T) {
t.RequireCapability(servicedef.CapabilityInstanceID)
// 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not to be that guy, but an SDK could have the ability to make a singleton and the ability to make instances. I supposed we don't do that nor do we expect to do that?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, the SDK wouldn't specify a capability of singleton like this. It's one of the earliest capabilities, and just designates SDKs that can ONLY create a single instance at a time, not that it has the ability to create a singleton. It should have been CapabilityMultiClient or something instead.

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
// 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, 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.FDv2SDKDataFromServerSDKData(
mockld.NewServerSDKDataBuilder().Build(), "none", "up-to-date", "initial")
dataSystem := NewSDKDataSystem(t, synchronizerData,
DataSystemOptionPollingInitializer(initializerData))
_ = NewSDKClient(t, c.baseSDKConfigurationPlus(dataSystem)...)
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) {
// 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)

secondaryData := mockld.FDv2SDKDataFromServerSDKData(
mockld.NewServerSDKDataBuilder().Build(), "xfer-full", "initial", "initial")
secondaryStream := mockld.NewStreamingService(
secondaryData, 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(),
}))...)

check := newInstanceIDChecker(t)
check(primaryEndpoint)
check(secondaryEndpoint)

verifyRequestHeader := func(t *ldtest.T, endpoint *harness.MockEndpoint) {
// 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)
})
Comment thread
cursor[bot] marked this conversation as resolved.
}

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"}},
c.emptyFDv1FallbackBody()),
t.DebugLogger(),
harness.MockEndpointDescription("FDv1 polling service"))
t.Defer(fdv1Endpoint.Close)

_ = 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(),
}),
WithFDv1Fallback(servicedef.SDKConfigPollingParams{
BaseURI: fdv1Endpoint.BaseURL(),
}))...)
Comment thread
cursor[bot] marked this conversation as resolved.

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)
})
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}

// 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)
Expand All @@ -83,7 +249,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) {
Expand All @@ -96,6 +263,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())
})
}
Loading
Loading