Verified Commit 1117b03c authored by Elliot Forbes's avatar Elliot Forbes 2️⃣ Committed by GitLab
Browse files

feat(v2/featureflag): add optional LRU evaluation cache to Client

parent a22d740a
Loading
Loading
Loading
Loading
+149 −0
Original line number Diff line number Diff line
package featureflag

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/hashicorp/golang-lru/v2/expirable"
	"github.com/open-feature/go-sdk/openfeature"
)

// Compile-time check that cachingProvider satisfies the FeatureProvider and StateHandler interfaces.
var (
	_ openfeature.FeatureProvider = (*cachingProvider)(nil)
	_ openfeature.StateHandler    = (*cachingProvider)(nil)
)

// cachingProvider is a FeatureProvider decorator that adds bounded LRU caching
// with per-entry TTL expiry. It wraps an inner FeatureProvider and intercepts
// BooleanEvaluation and StringEvaluation to serve cached results; all other
// evaluation methods are delegated directly.
type cachingProvider struct {
	inner       openfeature.FeatureProvider
	boolCache   *expirable.LRU[string, openfeature.BoolResolutionDetail]
	stringCache *expirable.LRU[string, openfeature.StringResolutionDetail]
}

// newCachingProvider wraps inner with LRU caching. size must be positive.
func newCachingProvider(inner openfeature.FeatureProvider, ttl time.Duration, size int) (*cachingProvider, error) {
	if size <= 0 {
		return nil, fmt.Errorf("featureflag: cache size must be positive")
	}
	return &cachingProvider{
		inner:       inner,
		boolCache:   expirable.NewLRU[string, openfeature.BoolResolutionDetail](size, nil, ttl),
		stringCache: expirable.NewLRU[string, openfeature.StringResolutionDetail](size, nil, ttl),
	}, nil
}

// Metadata delegates to the inner provider.
func (p *cachingProvider) Metadata() openfeature.Metadata {
	return p.inner.Metadata()
}

// Hooks delegates to the inner provider.
func (p *cachingProvider) Hooks() []openfeature.Hook {
	return p.inner.Hooks()
}

// Init forwards to the inner provider if it implements StateHandler.
func (p *cachingProvider) Init(evalCtx openfeature.EvaluationContext) error {
	if sh, ok := p.inner.(openfeature.StateHandler); ok {
		return sh.Init(evalCtx)
	}
	return nil
}

// Shutdown forwards to the inner provider if it implements StateHandler.
func (p *cachingProvider) Shutdown() {
	if sh, ok := p.inner.(openfeature.StateHandler); ok {
		sh.Shutdown()
	}
}

// buildCacheKey returns a stable string key for the given flag and flattened
// evaluation context. encoding/json marshals map[string]any keys alphabetically,
// producing a deterministic result.
func buildCacheKey(flagKey string, evalCtx openfeature.FlattenedContext) (string, error) {
	b, err := json.Marshal(map[string]any(evalCtx))
	if err != nil {
		return "", fmt.Errorf("featureflag: building cache key: %w", err)
	}
	return flagKey + ":" + string(b), nil
}

// BooleanEvaluation returns a cached result when available and unexpired;
// otherwise delegates to the inner provider and caches the result.
func (p *cachingProvider) BooleanEvaluation(
	ctx context.Context,
	flag string,
	defaultValue bool,
	evalCtx openfeature.FlattenedContext,
) openfeature.BoolResolutionDetail {
	key, err := buildCacheKey(flag, evalCtx)
	if err == nil {
		if detail, ok := p.boolCache.Get(key); ok {
			detail.Reason = openfeature.CachedReason
			return detail
		}
	}
	result := p.inner.BooleanEvaluation(ctx, flag, defaultValue, evalCtx)
	if err == nil {
		p.boolCache.Add(key, result)
	}
	return result
}

// StringEvaluation returns a cached result when available and unexpired;
// otherwise delegates to the inner provider and caches the result.
func (p *cachingProvider) StringEvaluation(
	ctx context.Context,
	flag string,
	defaultValue string,
	evalCtx openfeature.FlattenedContext,
) openfeature.StringResolutionDetail {
	key, err := buildCacheKey(flag, evalCtx)
	if err == nil {
		if detail, ok := p.stringCache.Get(key); ok {
			detail.Reason = openfeature.CachedReason
			return detail
		}
	}
	result := p.inner.StringEvaluation(ctx, flag, defaultValue, evalCtx)
	if err == nil {
		p.stringCache.Add(key, result)
	}
	return result
}

// FloatEvaluation delegates to the inner provider without caching.
func (p *cachingProvider) FloatEvaluation(
	ctx context.Context,
	flag string,
	defaultValue float64,
	evalCtx openfeature.FlattenedContext,
) openfeature.FloatResolutionDetail {
	return p.inner.FloatEvaluation(ctx, flag, defaultValue, evalCtx)
}

// IntEvaluation delegates to the inner provider without caching.
func (p *cachingProvider) IntEvaluation(
	ctx context.Context,
	flag string,
	defaultValue int64,
	evalCtx openfeature.FlattenedContext,
) openfeature.IntResolutionDetail {
	return p.inner.IntEvaluation(ctx, flag, defaultValue, evalCtx)
}

// ObjectEvaluation delegates to the inner provider without caching.
func (p *cachingProvider) ObjectEvaluation(
	ctx context.Context,
	flag string,
	defaultValue any,
	evalCtx openfeature.FlattenedContext,
) openfeature.InterfaceResolutionDetail {
	return p.inner.ObjectEvaluation(ctx, flag, defaultValue, evalCtx)
}
+250 −0
Original line number Diff line number Diff line
package featureflag

import (
	"context"
	"testing"
	"time"

	"github.com/open-feature/go-sdk/openfeature"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// staticProvider is a minimal FeatureProvider that returns a fixed bool value.
type staticProvider struct {
	boolValue bool
	calls     int
}

func (p *staticProvider) Metadata() openfeature.Metadata { return openfeature.Metadata{Name: "static"} }
func (p *staticProvider) Hooks() []openfeature.Hook      { return nil }

func (p *staticProvider) BooleanEvaluation(_ context.Context, _ string, _ bool, _ openfeature.FlattenedContext) openfeature.BoolResolutionDetail {
	p.calls++
	return openfeature.BoolResolutionDetail{Value: p.boolValue, ProviderResolutionDetail: openfeature.ProviderResolutionDetail{Reason: openfeature.StaticReason}}
}

func (p *staticProvider) StringEvaluation(_ context.Context, _ string, defaultValue string, _ openfeature.FlattenedContext) openfeature.StringResolutionDetail {
	p.calls++
	return openfeature.StringResolutionDetail{Value: defaultValue}
}

func (p *staticProvider) FloatEvaluation(_ context.Context, _ string, d float64, _ openfeature.FlattenedContext) openfeature.FloatResolutionDetail {
	return openfeature.FloatResolutionDetail{Value: d}
}

func (p *staticProvider) IntEvaluation(_ context.Context, _ string, d int64, _ openfeature.FlattenedContext) openfeature.IntResolutionDetail {
	return openfeature.IntResolutionDetail{Value: d}
}

func (p *staticProvider) ObjectEvaluation(_ context.Context, _ string, d any, _ openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail {
	return openfeature.InterfaceResolutionDetail{Value: d}
}

func newTestCachingProvider(t *testing.T, inner openfeature.FeatureProvider, ttl time.Duration, size int) *cachingProvider {
	t.Helper()
	p, err := newCachingProvider(inner, ttl, size)
	require.NoError(t, err)
	return p
}

func TestCacheKey(t *testing.T) {
	tests := []struct {
		name    string
		flagKey string
		evalCtx openfeature.FlattenedContext
		want    string
	}{
		{
			name:    "empty context produces stable key",
			flagKey: "my-flag",
			evalCtx: openfeature.FlattenedContext{},
			want:    `my-flag:{}`,
		},
		{
			name:    "context attributes included in key",
			flagKey: "my-flag",
			evalCtx: openfeature.FlattenedContext{"plan": "pro", "targetingKey": "user-1"},
			want:    `my-flag:{"plan":"pro","targetingKey":"user-1"}`,
		},
		{
			name:    "different flag keys produce different keys",
			flagKey: "other-flag",
			evalCtx: openfeature.FlattenedContext{},
			want:    `other-flag:{}`,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := buildCacheKey(tc.flagKey, tc.evalCtx)
			require.NoError(t, err)
			assert.Equal(t, tc.want, got)
		})
	}
}

func TestCachingProvider_BoolMissAndHit(t *testing.T) {
	tests := []struct {
		name          string
		callTwice     bool
		wantCallCount int
	}{
		{
			name:          "miss on first call reaches inner provider",
			callTwice:     false,
			wantCallCount: 1,
		},
		{
			name:          "hit on second call does not reach inner provider",
			callTwice:     true,
			wantCallCount: 1,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			inner := &staticProvider{boolValue: true}
			p := newTestCachingProvider(t, inner, 30*time.Second, 10)
			ctx := openfeature.FlattenedContext{}

			p.BooleanEvaluation(context.Background(), "test-flag", false, ctx)
			if tc.callTwice {
				p.BooleanEvaluation(context.Background(), "test-flag", false, ctx)
			}

			assert.Equal(t, tc.wantCallCount, inner.calls)
		})
	}
}

func TestCachingProvider_BoolExpiry(t *testing.T) {
	tests := []struct {
		name          string
		ttl           time.Duration
		sleepFor      time.Duration
		wantCallCount int
	}{
		{
			name:          "hit before TTL expires",
			ttl:           10 * time.Second,
			sleepFor:      0,
			wantCallCount: 1,
		},
		{
			name:          "miss after TTL expires",
			ttl:           time.Millisecond,
			sleepFor:      5 * time.Millisecond,
			wantCallCount: 2,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			inner := &staticProvider{boolValue: true}
			p := newTestCachingProvider(t, inner, tc.ttl, 10)
			ctx := openfeature.FlattenedContext{}

			p.BooleanEvaluation(context.Background(), "test-flag", false, ctx)
			time.Sleep(tc.sleepFor)
			p.BooleanEvaluation(context.Background(), "test-flag", false, ctx)

			assert.Equal(t, tc.wantCallCount, inner.calls)
		})
	}
}

func TestCachingProvider_LRUEviction(t *testing.T) {
	tests := []struct {
		name        string
		cacheSize   int
		fills       int
		wantEvicted bool
	}{
		{
			name:        "LRU entry evicted when cap exceeded",
			cacheSize:   2,
			fills:       3,
			wantEvicted: true,
		},
		{
			name:        "no eviction below cap",
			cacheSize:   10,
			fills:       3,
			wantEvicted: false,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			inner := &staticProvider{boolValue: true}
			p := newTestCachingProvider(t, inner, 30*time.Second, tc.cacheSize)

			// Fill the cache with distinct keys.
			for i := range tc.fills {
				ctx := openfeature.FlattenedContext{"targetingKey": string(rune('0' + i))}
				p.BooleanEvaluation(context.Background(), "test-flag", false, ctx)
			}

			callsAfterFill := inner.calls

			// Probe the first entry — should be evicted if cap was exceeded.
			firstCtx := openfeature.FlattenedContext{"targetingKey": "0"}
			p.BooleanEvaluation(context.Background(), "test-flag", false, firstCtx)

			if tc.wantEvicted {
				assert.Equal(t, callsAfterFill+1, inner.calls, "evicted entry should cause a provider call")
			} else {
				assert.Equal(t, callsAfterFill, inner.calls, "non-evicted entry should be served from cache")
			}
		})
	}
}

func TestCachingProvider_CachedReasonSet(t *testing.T) {
	inner := &staticProvider{boolValue: true}
	p := newTestCachingProvider(t, inner, 30*time.Second, 10)
	ctx := openfeature.FlattenedContext{}

	// First call: from provider.
	first := p.BooleanEvaluation(context.Background(), "test-flag", false, ctx)
	assert.NotEqual(t, openfeature.CachedReason, first.Reason)

	// Second call: from cache, reason should be CACHED.
	second := p.BooleanEvaluation(context.Background(), "test-flag", false, ctx)
	assert.Equal(t, openfeature.CachedReason, second.Reason)
}

func TestCachingProvider_StringMissAndHit(t *testing.T) {
	tests := []struct {
		name          string
		callTwice     bool
		wantCallCount int
	}{
		{
			name:          "miss on first call reaches inner provider",
			callTwice:     false,
			wantCallCount: 1,
		},
		{
			name:          "hit on second call does not reach inner provider",
			callTwice:     true,
			wantCallCount: 1,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			inner := &staticProvider{}
			p := newTestCachingProvider(t, inner, 30*time.Second, 10)
			ctx := openfeature.FlattenedContext{}

			p.StringEvaluation(context.Background(), "theme", "dark", ctx)
			if tc.callTwice {
				p.StringEvaluation(context.Background(), "theme", "dark", ctx)
			}

			assert.Equal(t, tc.wantCallCount, inner.calls)
		})
	}
}
+217 −0
Original line number Diff line number Diff line
package featureflag_test

import (
	"context"
	"fmt"
	"sync/atomic"
	"testing"
	"time"

	"github.com/open-feature/go-sdk/openfeature"
	"github.com/open-feature/go-sdk/openfeature/memprovider"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gitlab.com/gitlab-org/labkit/v2/featureflag"
)

// countingProvider wraps an InMemoryProvider and counts BooleanEvaluation calls.
type countingProvider struct {
	openfeature.FeatureProvider
	calls atomic.Int64
}

func (p *countingProvider) BooleanEvaluation(
	ctx context.Context, flag string, defaultValue bool, evalCtx openfeature.FlattenedContext,
) openfeature.BoolResolutionDetail {
	p.calls.Add(1)
	return p.FeatureProvider.BooleanEvaluation(ctx, flag, defaultValue, evalCtx)
}

func newCountingProvider(flags map[string]memprovider.InMemoryFlag) *countingProvider {
	return &countingProvider{FeatureProvider: memprovider.NewInMemoryProvider(flags)}
}

var testFlags = map[string]memprovider.InMemoryFlag{
	"my-flag": {
		Key:            "my-flag",
		State:          memprovider.Enabled,
		DefaultVariant: "on",
		Variants:       map[string]any{"on": true, "off": false},
	},
}

func TestNewWithConfig_CacheWiring(t *testing.T) {
	tests := []struct {
		name      string
		cacheTTL  time.Duration
		cacheSize int
		wantCache bool
	}{
		{
			name:      "no cache when TTL is zero",
			cacheTTL:  0,
			wantCache: false,
		},
		{
			name:      "cache allocated when TTL set",
			cacheTTL:  10 * time.Second,
			wantCache: true,
		},
		{
			name:      "explicit CacheSize respected",
			cacheTTL:  10 * time.Second,
			cacheSize: 500,
			wantCache: true,
		},
	}

	for i, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			p := newCountingProvider(testFlags)
			client, err := featureflag.NewWithConfig(context.Background(), &featureflag.Config{
				Name:      fmt.Sprintf("wire-test-%d", i),
				Provider:  p,
				CacheTTL:  tc.cacheTTL,
				CacheSize: tc.cacheSize,
			})
			require.NoError(t, err)
			t.Cleanup(func() { _ = client.Shutdown(context.Background()) })

			// First call
			_, err = client.BooleanValueDetails(context.Background(), "my-flag", false, openfeature.EvaluationContext{})
			require.NoError(t, err)
			// Second call
			_, err = client.BooleanValueDetails(context.Background(), "my-flag", false, openfeature.EvaluationContext{})
			require.NoError(t, err)

			if tc.wantCache {
				assert.Equal(t, int64(1), p.calls.Load(), "second call should be served from cache")
			} else {
				assert.Equal(t, int64(2), p.calls.Load(), "both calls should reach provider when cache disabled")
			}
		})
	}
}

func TestCache_Evaluation(t *testing.T) {
	tests := []struct {
		name              string
		cacheTTL          time.Duration
		evalCtxFirst      openfeature.EvaluationContext
		evalCtxSecond     openfeature.EvaluationContext
		sleepBetween      time.Duration
		wantProviderCalls int64
	}{
		{
			name:              "hit returns cached result",
			cacheTTL:          30 * time.Second,
			evalCtxFirst:      openfeature.EvaluationContext{},
			evalCtxSecond:     openfeature.EvaluationContext{},
			wantProviderCalls: 1,
		},
		{
			name:              "expired entry fetches fresh result",
			cacheTTL:          time.Millisecond,
			evalCtxFirst:      openfeature.EvaluationContext{},
			evalCtxSecond:     openfeature.EvaluationContext{},
			sleepBetween:      5 * time.Millisecond,
			wantProviderCalls: 2,
		},
		{
			name:              "different eval contexts cached independently",
			cacheTTL:          30 * time.Second,
			evalCtxFirst:      openfeature.NewEvaluationContext("user-1", nil),
			evalCtxSecond:     openfeature.NewEvaluationContext("user-2", nil),
			wantProviderCalls: 2,
		},
		{
			name:              "disabled when TTL is zero",
			cacheTTL:          0,
			evalCtxFirst:      openfeature.EvaluationContext{},
			evalCtxSecond:     openfeature.EvaluationContext{},
			wantProviderCalls: 2,
		},
	}

	for i, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			p := newCountingProvider(testFlags)
			client, err := featureflag.NewWithConfig(context.Background(), &featureflag.Config{
				Name:     fmt.Sprintf("eval-test-%d", i),
				Provider: p,
				CacheTTL: tc.cacheTTL,
			})
			require.NoError(t, err)
			t.Cleanup(func() { _ = client.Shutdown(context.Background()) })

			_, err = client.BooleanValueDetails(context.Background(), "my-flag", false, tc.evalCtxFirst)
			require.NoError(t, err)

			if tc.sleepBetween > 0 {
				time.Sleep(tc.sleepBetween)
			}

			_, err = client.BooleanValueDetails(context.Background(), "my-flag", false, tc.evalCtxSecond)
			require.NoError(t, err)

			assert.Equal(t, tc.wantProviderCalls, p.calls.Load())
		})
	}
}

func TestCache_Eviction(t *testing.T) {
	tests := []struct {
		name        string
		cacheSize   int
		fills       int
		wantEvicted bool
	}{
		{
			name:        "LRU entry evicted when cap exceeded",
			cacheSize:   2,
			fills:       3,
			wantEvicted: true,
		},
		{
			name:        "no eviction below cap",
			cacheSize:   10,
			fills:       3,
			wantEvicted: false,
		},
	}

	for i, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			p := newCountingProvider(testFlags)
			client, err := featureflag.NewWithConfig(context.Background(), &featureflag.Config{
				Name:      fmt.Sprintf("evict-test-%d", i),
				Provider:  p,
				CacheTTL:  30 * time.Second,
				CacheSize: tc.cacheSize,
			})
			require.NoError(t, err)
			t.Cleanup(func() { _ = client.Shutdown(context.Background()) })

			// Fill the cache with distinct eval contexts.
			for j := range tc.fills {
				ctx := openfeature.NewEvaluationContext(fmt.Sprintf("user-%d", j), nil)
				_, err = client.BooleanValueDetails(context.Background(), "my-flag", false, ctx)
				require.NoError(t, err)
			}

			callsAfterFill := p.calls.Load()

			// Probe the first entry (should be evicted if cap was exceeded).
			firstCtx := openfeature.NewEvaluationContext("user-0", nil)
			_, err = client.BooleanValueDetails(context.Background(), "my-flag", false, firstCtx)
			require.NoError(t, err)

			callsAfterProbe := p.calls.Load()
			if tc.wantEvicted {
				assert.Equal(t, callsAfterFill+1, callsAfterProbe, "evicted entry should cause a provider call")
			} else {
				assert.Equal(t, callsAfterFill, callsAfterProbe, "non-evicted entry should be served from cache")
			}
		})
	}
}
+49 −4
Original line number Diff line number Diff line
@@ -3,7 +3,9 @@ package featureflag
import (
	"context"
	"fmt"
	"net/http"
	"os"
	"time"

	flipt "github.com/open-feature/go-sdk-contrib/providers/flipt/pkg/provider"
	"github.com/open-feature/go-sdk/openfeature"
@@ -17,6 +19,12 @@ import (
// server address.
const endpointEnvVar = "FEATURE_FLAG_ENDPOINT"

// defaultHTTPClientTimeout is the overall timeout for Flipt feature flag
// evaluations when no custom HTTP client is provided. This is aggressive to
// ensure that slow or unresponsive Flipt services fail fast and allow
// graceful fallback to default flag values.
const defaultHTTPClientTimeout = 2 * time.Second

// Config holds optional configuration for [NewWithConfig].
type Config struct {
	// Endpoint is the address of the Flipt server (e.g. "http://flipt:8080").
@@ -41,6 +49,25 @@ type Config struct {
	// useful for testing (e.g. with [memprovider.NewInMemoryProvider]).
	// When set, Endpoint is ignored.
	Provider openfeature.FeatureProvider

	// CacheTTL sets how long a flag evaluation result is cached. Zero (default)
	// disables caching entirely — no cache is allocated and behaviour is unchanged.
	CacheTTL time.Duration

	// CacheSize caps the number of entries in the evaluation cache.
	// Defaults to 1000 when CacheTTL > 0 and CacheSize is zero.
	CacheSize int

	// HTTPClient is the HTTP client used by the Flipt provider for all requests.
	// When nil, a default client with a 2-second timeout is created. This timeout
	// is aggressive to ensure that slow or unresponsive Flipt services fail fast
	// and allow graceful fallback to default flag values.
	// Override this to configure a different timeout or custom transport:
	//
	//   cfg.HTTPClient = &http.Client{Timeout: 5 * time.Second}
	//
	// Ignored when Provider is set.
	HTTPClient *http.Client
}

// Evaluator is the interface for feature flag evaluation. It mirrors the
@@ -113,9 +140,31 @@ func NewWithConfig(ctx context.Context, cfg *Config) (*Client, error) {
		if cfg.Namespace != "" {
			opts = append(opts, flipt.ForNamespace(cfg.Namespace))
		}

		// Use the provided HTTP client, or create a default one with aggressive timeouts
		// to ensure Flipt hangs fail fast and allow graceful fallback to default values.
		httpClient := cfg.HTTPClient
		if httpClient == nil {
			httpClient = &http.Client{Timeout: defaultHTTPClientTimeout}
		}
		opts = append(opts, flipt.WithHTTPClient(httpClient))

		provider = flipt.NewProvider(opts...)
	}

	// Wrap the provider with a caching layer when a TTL is configured.
	if cfg.CacheTTL > 0 {
		var err error
		cacheSize := cfg.CacheSize
		if cacheSize <= 0 {
			cacheSize = 1000
		}
		provider, err = newCachingProvider(provider, cfg.CacheTTL, cacheSize)
		if err != nil {
			return nil, err
		}
	}

	// Use a domain-scoped provider so multiple Client instances with different
	// configurations do not interfere with each other's global state.
	if err := openfeature.SetNamedProviderAndWait(name, provider); err != nil {
@@ -168,11 +217,9 @@ func (c *Client) BooleanValueDetails(
	if c.tracer == nil {
		return c.of.BooleanValueDetails(ctx, flag, defaultValue, evalCtx, options...)
	}

	var span oteltrace.Span
	ctx, span = c.tracer.Start(ctx, "featureflag.eval")
	defer span.End()

	details, err := c.of.BooleanValueDetails(ctx, flag, defaultValue, evalCtx, options...)
	recordSpan(span, flag, details.Value, details.EvaluationDetails, err)
	return details, err
@@ -189,11 +236,9 @@ func (c *Client) StringValueDetails(
	if c.tracer == nil {
		return c.of.StringValueDetails(ctx, flag, defaultValue, evalCtx, options...)
	}

	var span oteltrace.Span
	ctx, span = c.tracer.Start(ctx, "featureflag.eval")
	defer span.End()

	details, err := c.of.StringValueDetails(ctx, flag, defaultValue, evalCtx, options...)
	recordSpan(span, flag, details.Value, details.EvaluationDetails, err)
	return details, err
+14 −0
Original line number Diff line number Diff line
@@ -3,7 +3,9 @@ package featureflag_test
import (
	"context"
	"fmt"
	"net/http"
	"testing"
	"time"

	"github.com/open-feature/go-sdk/openfeature"
	"github.com/open-feature/go-sdk/openfeature/memprovider"
@@ -299,3 +301,15 @@ func TestStringValueDetails(t *testing.T) {
		})
	}
}

func TestNewWithConfig_HTTPClientAccepted(t *testing.T) {
	// Verify that HTTPClient field is accepted when Provider is set (field is ignored).
	client, err := featureflag.NewWithConfig(context.Background(), &featureflag.Config{
		Name:       "http-client-provider-test",
		Provider:   inMemoryFlags(t, nil),
		HTTPClient: &http.Client{Timeout: 5 * time.Second},
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = client.Shutdown(context.Background()) })
	assert.NotNil(t, client)
}
Loading