Skip to content

fix(experimental): Functions for putting/getting an LDScopedClient from context.Context #305

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 14, 2025
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
61 changes: 61 additions & 0 deletions ldclient_gocontext.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package ldclient

import "context"

type scopedClientKey struct{}

// GoContextWithScopedClient adds a scoped client to the Go context. This can be
// used to pass a scoped client to a function or goroutine that might not
// otherwise have access to it:
//
// scopedClient := ld.NewScopedClient(client, ldUserContext)
// ctx := ld.GoContextWithScopedClient(context.Background(), scopedClient)
// otherFunction(ctx)
//
// This function is not stable, and not subject to any backwards compatibility
// guarantees or semantic versioning. It is not suitable for production usage. Do
// not use it. You have been warned.
func GoContextWithScopedClient(ctx context.Context, client *LDScopedClient) context.Context {
return context.WithValue(ctx, scopedClientKey{}, client)
}

// GetScopedClient retrieves a scoped client from the Go context that was set
// with GoContextWithScopedClient, if present. If not present, returns nil and
// false.
//
// func logicWithFeatureFlag(ctx context.Context) {
// scopedClient, ok := ld.GetScopedClient(ctx)
// isFeatureEnabled := false // default value if scoped client is not available
// if ok {
// isFeatureEnabled, err = scopedClient.BoolVariation("my-flag", false)
// // handle err as appropriate...
// }
// }
//
// This function is not stable, and not subject to any backwards compatibility
// guarantees or semantic versioning. It is not suitable for production usage. Do
// not use it. You have been warned.
func GetScopedClient(ctx context.Context) (*LDScopedClient, bool) {
client, ok := ctx.Value(scopedClientKey{}).(*LDScopedClient)
return client, ok
}

// MustGetScopedClient retrieves a scoped client from the Go context that was set
// with GoContextWithScopedClient, or panics if not present.
//
// func logicWithFeatureFlag(ctx context.Context) {
// scopedClient := ld.MustGetScopedClient(ctx)
// isFeatureEnabled, err := scopedClient.BoolVariation("my-flag", false)
// // handle err as appropriate...
// }
//
// This function is not stable, and not subject to any backwards compatibility
// guarantees or semantic versioning. It is not suitable for production usage. Do
// not use it. You have been warned.
func MustGetScopedClient(ctx context.Context) *LDScopedClient {
client, ok := GetScopedClient(ctx)
if !ok {
panic("No scoped client found in context")
}
return client
}
Copy link

Choose a reason for hiding this comment

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

Bug: Nil Client in Context Causes Panic

MustGetScopedClient returns nil without panicking if a typed nil *LDScopedClient is stored in the context. This occurs because GetScopedClient returns ok=true for a typed nil value, and GoContextWithScopedClient allows storing nil. Consequently, callers receive nil instead of a panic, leading to a later nil dereference at the point of use and defeating the "must" contract.

Additional Locations (1)
Fix in Cursor Fix in Web

40 changes: 40 additions & 0 deletions ldclient_gocontext_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package ldclient

import (
"context"
"testing"

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

func TestGetScopedClient(t *testing.T) {
t.Run("returns client from context", func(t *testing.T) {
origCtx := context.Background()
sc := &LDScopedClient{}

newCtx := GoContextWithScopedClient(origCtx, sc)
retrieved, ok := GetScopedClient(newCtx)

assert.True(t, ok, "expected to find scoped client in context")
assert.Equal(t, sc, retrieved, "retrieved client should match original")
})

t.Run("returns nil when not present", func(t *testing.T) {
retrieved, ok := GetScopedClient(context.Background())
assert.False(t, ok, "should not find scoped client in empty context")
assert.Nil(t, retrieved, "retrieved client should be nil when not present")
})
}

func TestMustGetScopedClient(t *testing.T) {
sc := &LDScopedClient{}
ctxWith := GoContextWithScopedClient(context.Background(), sc)

// Should return the client without panicking when present
assert.Equal(t, sc, MustGetScopedClient(ctxWith))

// Should panic when the client is not present
assert.Panics(t, func() {
MustGetScopedClient(context.Background())
})
}