Skip to content
Draft
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
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ require (
github.com/launchdarkly/eventsource v1.10.0
github.com/launchdarkly/go-jsonstream/v3 v3.1.1
github.com/launchdarkly/go-ntlm-proxy-auth v1.0.3
github.com/launchdarkly/go-sdk-common/v3 v3.5.0
github.com/launchdarkly/go-sdk-events/v3 v3.6.2
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d
github.com/launchdarkly/go-sdk-common/v3 v3.5.1-0.20260707203913-381c033e045f
github.com/launchdarkly/go-sdk-events/v3 v3.6.3-0.20260707205009-8af496ae2ab5
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707205605-a8cc07261329
github.com/launchdarkly/go-test-helpers/v3 v3.1.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/stretchr/testify v1.9.0
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,20 @@ github.com/launchdarkly/go-ntlmssp v1.0.3 h1:rFxOnnEJ2DzJ+NU0plhXqnldJUwn3wWJFTW
github.com/launchdarkly/go-ntlmssp v1.0.3/go.mod h1:P1z6fX/y9zgBvfnZP7AKWilW9AX5M3czsa1S4Zpp2nM=
github.com/launchdarkly/go-sdk-common/v3 v3.5.0 h1:DsfTimg4BZO2hQPeeeEZYZVsP25pAWEdq/rKv/b7HJU=
github.com/launchdarkly/go-sdk-common/v3 v3.5.0/go.mod h1:6MNeeP8b2VtsM6I3TbShCHW/+tYh2c+p5dB+ilS69sg=
github.com/launchdarkly/go-sdk-common/v3 v3.5.1-0.20260707203913-381c033e045f h1:wsrL79voFEPH70Dc6E6B9dnqWlS9nnwiKwHHw3SX7Fw=
github.com/launchdarkly/go-sdk-common/v3 v3.5.1-0.20260707203913-381c033e045f/go.mod h1:6MNeeP8b2VtsM6I3TbShCHW/+tYh2c+p5dB+ilS69sg=
github.com/launchdarkly/go-sdk-events/v3 v3.6.2 h1:X5sxi1Hz5Cj8wOyXiNnJzCcCGiGIy9IOSEk31lCfDXM=
github.com/launchdarkly/go-sdk-events/v3 v3.6.2/go.mod h1:s86HRgbVAHjoQjQEOzgOB+ZKWlU4wwHAeDa/5N6MwO0=
github.com/launchdarkly/go-sdk-events/v3 v3.6.3-0.20260707205009-8af496ae2ab5 h1:19MHm77s549uaFl3iP/+g7T98FNQt1PF5sPITH93HKo=
github.com/launchdarkly/go-sdk-events/v3 v3.6.3-0.20260707205009-8af496ae2ab5/go.mod h1:s86HRgbVAHjoQjQEOzgOB+ZKWlU4wwHAeDa/5N6MwO0=
github.com/launchdarkly/go-semver v1.0.3 h1:agIy/RN3SqeQDIfKkl+oFslEdeIs7pgsJBs3CdCcGQM=
github.com/launchdarkly/go-semver v1.0.3/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28=
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 h1:rTgcYAFraGFj7sBMB2b7JCYCm0b9kph4FaMX02t4osQ=
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58=
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d h1:ZtI+ZGVhnUaLOLaG20tt7hN3G+i5PiqE2lFZNMzvUQM=
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707204200-c3652610871d/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58=
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707205605-a8cc07261329 h1:+Jg/Xrqg5ZaOdagIHv386xVkm/EbjXWcZTn1h1DQPls=
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.2-0.20260707205605-a8cc07261329/go.mod h1:rDPT4v+kM13xg1hgTZBwiv3UEgArn8jJi+TIelyx36k=
github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg=
github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
Expand Down
14 changes: 11 additions & 3 deletions ldclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -790,9 +790,14 @@ func (client *LDClient) AllFlagsState(context ldcontext.Context, options ...flag
client.loggers.Warn("Called AllFlagsState in offline mode. Returning empty state")
valid = false
} else if client.dataSystem.DataAvailability() != datasystem.Refreshed {
if client.dataSystem.DataAvailability() == datasystem.Cached {
switch {
case client.dataSystem.DataAvailability() == datasystem.Cached:
client.loggers.Warn("Called AllFlagsState before client initialization; using last known values from data store")
} else {
case client.dataSystem.HasOverrides():
// The merged view holds only the override entries, so the state is valid but
// contains only the overridden flags.
client.loggers.Warn("Called AllFlagsState before client initialization; returning only flags from the override layer") //nolint:lll
default:
client.loggers.Warn("Called AllFlagsState before client initialization. Data store not available; returning empty state") //nolint:lll
valid = false
}
Expand Down Expand Up @@ -1348,6 +1353,7 @@ func (client *LDClient) variationAndFlag(
Version: flag.Version,
RequireFullEvent: flag.TrackEvents,
DebugEventsUntilDate: flag.DebugEventsUntilDate,
IsOverride: flag.IsOverride,
},
ldevents.Context(context),
result.Detail,
Expand Down Expand Up @@ -1394,7 +1400,9 @@ func (client *LDClient) evaluateInternal(
if client.dataSystem.DataAvailability() != datasystem.Refreshed {
if client.dataSystem.DataAvailability() == datasystem.Cached {
client.loggers.Warn("Feature flag evaluation called before LaunchDarkly client initialization completed; using last known values from data store") //nolint:lll
} else {
} else if !client.dataSystem.HasFlagOverride(key) {
// A flag present in the override layer is served even though no data is available
// from LaunchDarkly; the store read below sees it through the override overlay.
return evalErrorResult(ldreason.EvalErrorClientNotReady, nil, ErrClientNotInitialized)
}
}
Expand Down
1 change: 1 addition & 0 deletions ldclient_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func newEventsScope(client *LDClient, withReasons bool) eventsScope {
Version: params.PrerequisiteFlag.Version,
RequireFullEvent: params.PrerequisiteFlag.TrackEvents,
DebugEventsUntilDate: params.PrerequisiteFlag.DebugEventsUntilDate,
IsOverride: params.PrerequisiteFlag.IsOverride,
},
ldevents.Context(params.Context),
params.PrerequisiteResult.Detail,
Expand Down
195 changes: 195 additions & 0 deletions ldclient_overrides_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package ldclient

import (
"context"
"errors"
"testing"
"time"

"github.com/launchdarkly/go-sdk-common/v3/ldreason"
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
ldevents "github.com/launchdarkly/go-sdk-events/v3"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel"
"github.com/launchdarkly/go-server-sdk/v7/internal/datakinds"
"github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest"
"github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest/mocks"
"github.com/launchdarkly/go-server-sdk/v7/ldcomponents"
"github.com/launchdarkly/go-server-sdk/v7/subsystems"
st "github.com/launchdarkly/go-server-sdk/v7/subsystems/ldstoretypes"

"github.com/launchdarkly/go-sdk-common/v3/ldlogtest"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// hangingSynchronizer is a DataSynchronizer that connects but never yields data, leaving
// the client permanently uninitialized.
type hangingSynchronizer struct {
quit chan struct{}
}

func newHangingSynchronizer() *hangingSynchronizer {
return &hangingSynchronizer{quit: make(chan struct{})}
}

func (h *hangingSynchronizer) Name() string { return "HangingSynchronizer" }

func (h *hangingSynchronizer) Fetch(ds subsystems.DataSelector, ctx context.Context) (*subsystems.Basis, bool, error) {
return nil, false, errors.New("no data available")
}

func (h *hangingSynchronizer) Sync(ds subsystems.DataSelector) <-chan subsystems.DataSynchronizerResult {
results := make(chan subsystems.DataSynchronizerResult)
go func() {
<-h.quit
close(results)
}()
return results
}

func (h *hangingSynchronizer) Close() error {
close(h.quit)
return nil
}

func (h *hangingSynchronizer) Build(subsystems.ClientContext) (subsystems.DataSynchronizer, error) {
return h, nil
}

func overrideTestFlagData(flags ...ldmodel.FeatureFlag) []st.Collection {
coll := st.Collection{Kind: datakinds.Features}
for _, flag := range flags {
coll.Items = append(coll.Items,
st.KeyedItemDescriptor{Key: flag.Key, Item: sharedtest.FlagDescriptor(flag)})
}
return []st.Collection{coll}
}

func singleValueFlag(key string, value ldvalue.Value) ldmodel.FeatureFlag {
return ldbuilders.NewFlagBuilder(key).SingleVariation(value).Build()
}

// makeUninitializedClientWithOverrides builds a client whose data system can never obtain
// LaunchDarkly data, with the given override source contents.
func makeUninitializedClientWithOverrides(
t *testing.T,
source *sharedtest.TestOverrideSource,
events ldevents.EventProcessor,
) *LDClient {
t.Helper()
config := Config{
Logging: ldcomponents.Logging().Loggers(ldlogtest.NewMockLog().Loggers),
DataSystem: ldcomponents.DataSystem().Custom().
Synchronizers(newHangingSynchronizer()).
Overrides(source),
}
if events == nil {
config.Events = ldcomponents.NoEvents()
} else {
config.Events = mocks.SingleComponentConfigurer[ldevents.EventProcessor]{Instance: events}
}
client, _ := MakeCustomClient(testSdkKey, config, time.Duration(0))
require.NotNil(t, client)
t.Cleanup(func() { _ = client.Close() })
return client
}

func TestOverrideIsServedWhenClientIsNotInitialized(t *testing.T) {
source := sharedtest.NewTestOverrideSource(
overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true))))
client := makeUninitializedClientWithOverrides(t, source, nil)

require.False(t, client.Initialized())

value, detail, err := client.BoolVariationDetail("overridden-flag", evalTestUser, false)
require.NoError(t, err)
assert.True(t, value)
assert.Equal(t, ldreason.EvalReasonOff, detail.Reason.GetKind())
assert.True(t, detail.Reason.IsOverride())
}

func TestNonOverriddenFlagStillShortCircuitsWhenClientIsNotInitialized(t *testing.T) {
source := sharedtest.NewTestOverrideSource(
overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true))))
client := makeUninitializedClientWithOverrides(t, source, nil)

value, detail, err := client.BoolVariationDetail("other-flag", evalTestUser, false)
assert.Equal(t, ErrClientNotInitialized, err)
assert.False(t, value)
assert.Equal(t, ldreason.NewEvalReasonError(ldreason.EvalErrorClientNotReady), detail.Reason)
}

func TestOverrideRemovalRestoresShortCircuit(t *testing.T) {
source := sharedtest.NewTestOverrideSource(
overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true))))
client := makeUninitializedClientWithOverrides(t, source, nil)

value, err := client.BoolVariation("overridden-flag", evalTestUser, false)
require.NoError(t, err)
require.True(t, value)

source.SetOverrides(nil)

value, err = client.BoolVariation("overridden-flag", evalTestUser, false)
assert.Equal(t, ErrClientNotInitialized, err)
assert.False(t, value)
}

func TestAllFlagsStateContainsOnlyOverridesWhenClientIsNotInitialized(t *testing.T) {
source := sharedtest.NewTestOverrideSource(
overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true))))
client := makeUninitializedClientWithOverrides(t, source, nil)

state := client.AllFlagsState(evalTestUser)
assert.True(t, state.IsValid())
values := state.ToValuesMap()
require.Len(t, values, 1)
assert.Equal(t, ldvalue.Bool(true), values["overridden-flag"])
}

func TestAllFlagsStateIsInvalidWhenNotInitializedAndOverrideLayerIsEmpty(t *testing.T) {
source := sharedtest.NewTestOverrideSource(nil)
client := makeUninitializedClientWithOverrides(t, source, nil)

state := client.AllFlagsState(evalTestUser)
assert.False(t, state.IsValid())
assert.Len(t, state.ToValuesMap(), 0)
}

func TestOverrideEvaluationEventsCarryOverrideMarker(t *testing.T) {
events := &mocks.CapturingEventProcessor{}
source := sharedtest.NewTestOverrideSource(
overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true))))
client := makeUninitializedClientWithOverrides(t, source, events)

_, err := client.BoolVariation("overridden-flag", evalTestUser, false)
require.NoError(t, err)

var evalData []ldevents.EvaluationData
for _, e := range events.Events {
if ed, ok := e.(ldevents.EvaluationData); ok {
evalData = append(evalData, ed)
}
}
require.Len(t, evalData, 1)
assert.True(t, evalData[0].IsOverride)
assert.Equal(t, "overridden-flag", evalData[0].Key)
}

func TestFlagTrackerIsNotifiedOfOverrideChanges(t *testing.T) {
source := sharedtest.NewTestOverrideSource(nil)
client := makeUninitializedClientWithOverrides(t, source, nil)

listener := client.GetFlagTracker().AddFlagChangeListener()

source.SetOverrides(overrideTestFlagData(singleValueFlag("overridden-flag", ldvalue.Bool(true))))

select {
case event := <-listener:
assert.Equal(t, "overridden-flag", event.Key)
case <-time.After(5 * time.Second):
require.FailNow(t, "timed out waiting for flag change event")
}
}
Loading