diff --git a/.golangci.yml b/.golangci.yml index a385069a..100df06e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,12 +1,13 @@ linters-settings: govet: - check-shadowing: true + enable: + - shadow + # golint removed in newer versions, but works in v1.54.2 golint: min-confidence: 0 gocyclo: min-complexity: 16 - maligned: - suggest-new: true + # maligned removed - replaced by govet fieldalignment dupl: threshold: 100 goconst: @@ -31,40 +32,52 @@ linters-settings: linters: disable-all: true enable: - - megacheck - - golint + # Core linters - govet - - unconvert - - megacheck - - structcheck - - gas - - gocyclo - - dupl - - misspell - - unparam - - varcheck - - deadcode - typecheck - ineffassign - - varcheck + - gofmt + + # Static analysis + - staticcheck + - gosimple + - unused + + # Security + - gosec + + # Style and quality + - golint # deprecated but still works in v1.54.2 - stylecheck - #- gochecknoinits - - scopelint + + # Code complexity + - gocyclo + - dupl - gocritic - - golint - nakedret - - gosimple - - prealloc - - maligned - - gofmt + + # Performance + - prealloc + - unconvert + + # Correctness + - unparam + - misspell + + # Replacing deprecated linters + - exportloopref # replaces scopelint (works in v1.54.2) + # - fieldalignment # replaces maligned (in govet) fast: false + + # Enable additional checks + enable-all: false run: - skip-dirs: - - vendor concurrency: 4 issues: + exclude-dirs: + - vendor exclude-rules: - text: "weak cryptographic primitive" linters: @@ -72,4 +85,4 @@ issues: exclude-use-default: false service: - golangci-lint-version: 1.17.x + golangci-lint-version: 1.54.x diff --git a/pkg/client/client.go b/pkg/client/client.go index 868e163b..1f1ce75f 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2024, Optimizely, Inc. and contributors * + * Copyright 2019-2025, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -173,6 +173,7 @@ func (o *OptimizelyClient) decide(userContext *OptimizelyUserContext, key string Attributes: userContext.GetUserAttributes(), QualifiedSegments: userContext.GetQualifiedSegments(), } + var variationKey string var eventSent, flagEnabled bool allOptions := o.getAllOptions(options) @@ -469,7 +470,7 @@ func (o *OptimizelyClient) Activate(experimentKey string, userContext entities.U } // IsFeatureEnabled returns true if the feature is enabled for the given user. If the user is part of a feature test -// then an impression event will be queued up to be sent to the Optimizely log endpoint for results processing. +// then an impression event will be queued up to the Optimizely log endpoint for results processing. func (o *OptimizelyClient) IsFeatureEnabled(featureKey string, userContext entities.UserContext) (result bool, err error) { defer func() { diff --git a/pkg/client/factory.go b/pkg/client/factory.go index e4a59d53..c05f70a2 100644 --- a/pkg/client/factory.go +++ b/pkg/client/factory.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020,2022-2024 Optimizely, Inc. and contributors * + * Copyright 2019-2020,2022-2025 Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -22,6 +22,7 @@ import ( "errors" "time" + "github.com/optimizely/go-sdk/v2/pkg/cmab" "github.com/optimizely/go-sdk/v2/pkg/config" "github.com/optimizely/go-sdk/v2/pkg/decide" "github.com/optimizely/go-sdk/v2/pkg/decision" @@ -53,6 +54,7 @@ type OptimizelyFactory struct { overrideStore decision.ExperimentOverrideStore userProfileService decision.UserProfileService notificationCenter notification.Center + cmabConfig *cmab.Config // ODP segmentsCacheSize int @@ -159,6 +161,10 @@ func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClie if f.overrideStore != nil { experimentServiceOptions = append(experimentServiceOptions, decision.WithOverrideStore(f.overrideStore)) } + // Add CMAB config option if provided + if f.cmabConfig != nil { + experimentServiceOptions = append(experimentServiceOptions, decision.WithCmabConfig(f.cmabConfig)) + } compositeExperimentService := decision.NewCompositeExperimentService(f.SDKKey, experimentServiceOptions...) compositeService := decision.NewCompositeService(f.SDKKey, decision.WithCompositeExperimentService(compositeExperimentService)) appClient.DecisionService = compositeService @@ -320,6 +326,13 @@ func WithTracer(tracer tracing.Tracer) OptionFunc { } } +// WithCmabConfig sets the CMAB configuration options +func WithCmabConfig(cmabConfig *cmab.Config) OptionFunc { + return func(f *OptimizelyFactory) { + f.cmabConfig = cmabConfig + } +} + // StaticClient returns a client initialized with a static project config. func (f *OptimizelyFactory) StaticClient() (optlyClient *OptimizelyClient, err error) { diff --git a/pkg/client/factory_test.go b/pkg/client/factory_test.go index fb6326a2..fe878ec4 100644 --- a/pkg/client/factory_test.go +++ b/pkg/client/factory_test.go @@ -28,6 +28,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/optimizely/go-sdk/v2/pkg/cache" + "github.com/optimizely/go-sdk/v2/pkg/cmab" "github.com/optimizely/go-sdk/v2/pkg/config" "github.com/optimizely/go-sdk/v2/pkg/decide" "github.com/optimizely/go-sdk/v2/pkg/decision" @@ -434,3 +435,141 @@ func TestConvertDecideOptionsWithCMABOptions(t *testing.T) { assert.True(t, convertedOptions.ResetCMABCache) assert.True(t, convertedOptions.InvalidateUserCMABCache) } + +func TestAllOptionFunctions(t *testing.T) { + f := &OptimizelyFactory{} + + // Test all option functions to ensure they're covered + WithDatafileAccessToken("token")(f) + WithSegmentsCacheSize(123)(f) + WithSegmentsCacheTimeout(2 * time.Second)(f) + WithOdpDisabled(true)(f) + + // Verify some options were set + assert.Equal(t, "token", f.DatafileAccessToken) + assert.Equal(t, 123, f.segmentsCacheSize) + assert.True(t, f.odpDisabled) +} + +func TestStaticClientError(t *testing.T) { + // Use invalid datafile to force an error + factory := OptimizelyFactory{Datafile: []byte("invalid json"), SDKKey: ""} + client, err := factory.StaticClient() + assert.Error(t, err) + assert.Nil(t, client) +} + +func TestFactoryWithCmabConfig(t *testing.T) { + factory := OptimizelyFactory{} + cmabConfig := cmab.Config{ + CacheSize: 100, + CacheTTL: time.Minute, + HTTPTimeout: 30 * time.Second, + RetryConfig: &cmab.RetryConfig{ + MaxRetries: 5, + }, + } + + // Test the option function + WithCmabConfig(&cmabConfig)(&factory) + + assert.Equal(t, &cmabConfig, factory.cmabConfig) + assert.Equal(t, 100, factory.cmabConfig.CacheSize) + assert.Equal(t, time.Minute, factory.cmabConfig.CacheTTL) + assert.Equal(t, 30*time.Second, factory.cmabConfig.HTTPTimeout) + assert.NotNil(t, factory.cmabConfig.RetryConfig) + assert.Equal(t, 5, factory.cmabConfig.RetryConfig.MaxRetries) +} + +func TestFactoryCmabConfigPassedToDecisionService(t *testing.T) { + // Test that CMAB config is correctly passed to decision service when creating client + cmabConfig := cmab.Config{ + CacheSize: 200, + CacheTTL: 2 * time.Minute, + HTTPTimeout: 20 * time.Second, + RetryConfig: &cmab.RetryConfig{ + MaxRetries: 3, + }, + } + + factory := OptimizelyFactory{ + SDKKey: "test_sdk_key", + cmabConfig: &cmabConfig, + } + + // Verify the config is set + assert.Equal(t, &cmabConfig, factory.cmabConfig) + assert.Equal(t, 200, factory.cmabConfig.CacheSize) + assert.Equal(t, 2*time.Minute, factory.cmabConfig.CacheTTL) + assert.NotNil(t, factory.cmabConfig.RetryConfig) +} + +func TestFactoryOptionFunctions(t *testing.T) { + factory := &OptimizelyFactory{} + + // Test all option functions to ensure they're covered + WithDatafileAccessToken("test_token")(factory) + WithSegmentsCacheSize(100)(factory) + WithSegmentsCacheTimeout(5 * time.Second)(factory) + WithOdpDisabled(true)(factory) + WithCmabConfig(&cmab.Config{CacheSize: 50})(factory) + + // Verify options were set + assert.Equal(t, "test_token", factory.DatafileAccessToken) + assert.Equal(t, 100, factory.segmentsCacheSize) + assert.Equal(t, 5*time.Second, factory.segmentsCacheTimeout) + assert.True(t, factory.odpDisabled) + assert.Equal(t, &cmab.Config{CacheSize: 50}, factory.cmabConfig) +} + +func TestWithCmabConfigOption(t *testing.T) { + factory := &OptimizelyFactory{} + testConfig := cmab.Config{ + CacheSize: 200, + CacheTTL: 2 * time.Minute, + } + WithCmabConfig(&testConfig)(factory) + assert.Equal(t, &testConfig, factory.cmabConfig) +} + +func TestClientWithCmabConfig(t *testing.T) { + // Test client creation with non-empty CMAB config (tests reflect.DeepEqual path) + cmabConfig := cmab.Config{ + CacheSize: 200, + CacheTTL: 5 * time.Minute, + HTTPTimeout: 30 * time.Second, + RetryConfig: &cmab.RetryConfig{ + MaxRetries: 5, + }, + } + + factory := OptimizelyFactory{ + SDKKey: "test_sdk_key", + } + + client, err := factory.Client(WithCmabConfig(&cmabConfig)) + assert.NoError(t, err) + assert.NotNil(t, client) + + // Verify the CMAB config was applied by checking if DecisionService exists + // This tests the reflect.DeepEqual check on lines 166-167 + assert.NotNil(t, client.DecisionService) + client.Close() +} + +func TestClientWithEmptyCmabConfig(t *testing.T) { + // Test client creation with empty CMAB config (tests reflect.DeepEqual returns true) + emptyCmabConfig := cmab.Config{} + + factory := OptimizelyFactory{ + SDKKey: "test_sdk_key", + } + + client, err := factory.Client(WithCmabConfig(&emptyCmabConfig)) + assert.NoError(t, err) + assert.NotNil(t, client) + + // Verify client still works with empty config + assert.NotNil(t, client.DecisionService) + client.Close() +} diff --git a/pkg/client/optimizely_decision.go b/pkg/client/optimizely_decision.go index 588e7cb5..c9454758 100644 --- a/pkg/client/optimizely_decision.go +++ b/pkg/client/optimizely_decision.go @@ -33,7 +33,13 @@ type OptimizelyDecision struct { } // NewOptimizelyDecision creates and returns a new instance of OptimizelyDecision -func NewOptimizelyDecision(variationKey, ruleKey, flagKey string, enabled bool, variables *optimizelyjson.OptimizelyJSON, userContext OptimizelyUserContext, reasons []string) OptimizelyDecision { +func NewOptimizelyDecision( + variationKey, ruleKey, flagKey string, + enabled bool, + variables *optimizelyjson.OptimizelyJSON, + userContext OptimizelyUserContext, + reasons []string, +) OptimizelyDecision { return OptimizelyDecision{ VariationKey: variationKey, Enabled: enabled, diff --git a/pkg/cmab/client.go b/pkg/cmab/client.go index 8a010e3c..bfb7c9fa 100644 --- a/pkg/cmab/client.go +++ b/pkg/cmab/client.go @@ -136,6 +136,9 @@ func (c *DefaultCmabClient) FetchDecision( // Create the URL url := fmt.Sprintf(CMABPredictionEndpoint, ruleID) + // Log the URL being called + c.logger.Debug(fmt.Sprintf("CMAB Prediction URL: %s", url)) + // Convert attributes to CMAB format cmabAttributes := make([]Attribute, 0, len(attributes)) for key, value := range attributes { @@ -164,12 +167,15 @@ func (c *DefaultCmabClient) FetchDecision( return "", fmt.Errorf("failed to marshal CMAB request: %w", err) } + // Log the request body + c.logger.Debug(fmt.Sprintf("CMAB request body: %s", string(bodyBytes))) + // If no retry config, just do a single fetch if c.retryConfig == nil { return c.doFetch(context.Background(), url, bodyBytes) } - // Retry sending request with exponential backoff + // Retry with exponential backoff var lastErr error for i := 0; i <= c.retryConfig.MaxRetries; i++ { // Make the request @@ -179,37 +185,21 @@ func (c *DefaultCmabClient) FetchDecision( } lastErr = err + c.logger.Warning(fmt.Sprintf("CMAB API request failed (attempt %d/%d): %v", + i+1, c.retryConfig.MaxRetries+1, err)) // Don't wait after the last attempt if i < c.retryConfig.MaxRetries { - backoffDuration := c.retryConfig.InitialBackoff * time.Duration(1< c.retryConfig.MaxBackoff { + backoffDuration = c.retryConfig.MaxBackoff + } + c.logger.Debug(fmt.Sprintf("CMAB request retry with backoff: %v", backoffDuration)) time.Sleep(backoffDuration) } - - // Calculate backoff duration - backoffDuration := c.retryConfig.InitialBackoff * time.Duration(math.Pow(c.retryConfig.BackoffMultiplier, float64(i))) - if backoffDuration > c.retryConfig.MaxBackoff { - backoffDuration = c.retryConfig.MaxBackoff - } - - c.logger.Debug(fmt.Sprintf("CMAB request retry %d/%d, backing off for %v", - i+1, c.retryConfig.MaxRetries, backoffDuration)) - - // Wait for backoff duration with context awareness - select { - case <-context.Background().Done(): - return "", fmt.Errorf("context canceled or timed out during backoff: %w", context.Background().Err()) - case <-time.After(backoffDuration): - // Continue with retry - } - - c.logger.Warning(fmt.Sprintf("CMAB API request failed (attempt %d/%d): %v", - i+1, c.retryConfig.MaxRetries, err)) } - // This should never be reached due to the return in the loop above return "", fmt.Errorf("failed to fetch CMAB decision after %d attempts: %w", c.retryConfig.MaxRetries, lastErr) } @@ -242,6 +232,9 @@ func (c *DefaultCmabClient) doFetch(ctx context.Context, url string, bodyBytes [ return "", fmt.Errorf("failed to read CMAB response body: %w", err) } + // Log the raw response + c.logger.Debug(fmt.Sprintf("CMAB raw response: %s", string(respBody))) + // Parse response var cmabResponse Response if err := json.Unmarshal(respBody, &cmabResponse); err != nil { @@ -253,8 +246,12 @@ func (c *DefaultCmabClient) doFetch(ctx context.Context, url string, bodyBytes [ return "", fmt.Errorf("invalid CMAB response: missing predictions or variation_id") } + // Log the parsed variation ID + variationID := cmabResponse.Predictions[0].VariationID + c.logger.Debug(fmt.Sprintf("CMAB parsed variation ID: %s", variationID)) + // Return the variation ID - return cmabResponse.Predictions[0].VariationID, nil + return variationID, nil } // validateResponse validates the CMAB response diff --git a/pkg/cmab/config.go b/pkg/cmab/config.go new file mode 100644 index 00000000..746b3f4f --- /dev/null +++ b/pkg/cmab/config.go @@ -0,0 +1,55 @@ +/**************************************************************************** + * Copyright 2025, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package cmab provides contextual multi-armed bandit functionality +package cmab + +import ( + "time" + + "github.com/optimizely/go-sdk/v2/pkg/cache" +) + +const ( + // DefaultCacheSize is the default size for CMAB cache + DefaultCacheSize = 100 + // DefaultCacheTTL is the default TTL for CMAB cache (30 minutes to match agent) + DefaultCacheTTL = 30 * time.Minute + + // DefaultHTTPTimeout is the default HTTP timeout for CMAB requests + DefaultHTTPTimeout = 10 * time.Second +) + +// Config holds CMAB configuration options +type Config struct { + CacheSize int + CacheTTL time.Duration + HTTPTimeout time.Duration + RetryConfig *RetryConfig + Cache cache.CacheWithRemove // Custom cache implementation (Redis, etc.) +} + +// NewDefaultConfig creates a Config with default values +func NewDefaultConfig() Config { + return Config{ + CacheSize: DefaultCacheSize, + CacheTTL: DefaultCacheTTL, + HTTPTimeout: DefaultHTTPTimeout, + RetryConfig: &RetryConfig{ + MaxRetries: DefaultMaxRetries, + }, + } +} diff --git a/pkg/cmab/config_test.go b/pkg/cmab/config_test.go new file mode 100644 index 00000000..bbc643d4 --- /dev/null +++ b/pkg/cmab/config_test.go @@ -0,0 +1,41 @@ +/**************************************************************************** + * Copyright 2025, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +package cmab + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewDefaultConfig(t *testing.T) { + config := NewDefaultConfig() + + assert.Equal(t, DefaultCacheSize, config.CacheSize) + assert.Equal(t, DefaultCacheTTL, config.CacheTTL) + assert.Equal(t, DefaultHTTPTimeout, config.HTTPTimeout) + assert.NotNil(t, config.RetryConfig) + assert.Equal(t, DefaultMaxRetries, config.RetryConfig.MaxRetries) + assert.Nil(t, config.Cache) // Should be nil by default +} + +func TestDefaultConstants(t *testing.T) { + assert.Equal(t, 100, DefaultCacheSize) + assert.Equal(t, 30*time.Minute, DefaultCacheTTL) + assert.Equal(t, 10*time.Second, DefaultHTTPTimeout) +} diff --git a/pkg/decision/composite_experiment_service.go b/pkg/decision/composite_experiment_service.go index e8054bb6..dc75a20b 100644 --- a/pkg/decision/composite_experiment_service.go +++ b/pkg/decision/composite_experiment_service.go @@ -18,6 +18,7 @@ package decision import ( + "github.com/optimizely/go-sdk/v2/pkg/cmab" "github.com/optimizely/go-sdk/v2/pkg/decide" "github.com/optimizely/go-sdk/v2/pkg/entities" "github.com/optimizely/go-sdk/v2/pkg/logging" @@ -40,11 +41,19 @@ func WithOverrideStore(overrideStore ExperimentOverrideStore) CESOptionFunc { } } +// WithCmabConfig sets the CMAB configuration. +func WithCmabConfig(config *cmab.Config) CESOptionFunc { + return func(f *CompositeExperimentService) { + f.cmabConfig = config + } +} + // CompositeExperimentService bridges together the various experiment decision services that ship by default with the SDK type CompositeExperimentService struct { experimentServices []ExperimentService overrideStore ExperimentOverrideStore userProfileService UserProfileService + cmabConfig *cmab.Config logger logging.OptimizelyLogProducer } @@ -61,7 +70,9 @@ func NewCompositeExperimentService(sdkKey string, options ...CESOptionFunc) *Com // 2. Whitelist // 3. CMAB (always created) // 4. Bucketing (with User profile integration if supplied) - compositeExperimentService := &CompositeExperimentService{logger: logging.GetLogger(sdkKey, "CompositeExperimentService")} + compositeExperimentService := &CompositeExperimentService{ + logger: logging.GetLogger(sdkKey, "CompositeExperimentService"), + } for _, opt := range options { opt(compositeExperimentService) @@ -76,8 +87,8 @@ func NewCompositeExperimentService(sdkKey string, options ...CESOptionFunc) *Com experimentServices = append([]ExperimentService{overrideService}, experimentServices...) } - // Create CMAB service with all initialization handled internally - experimentCmabService := NewExperimentCmabService(sdkKey) + // Create CMAB service with config + experimentCmabService := NewExperimentCmabService(sdkKey, compositeExperimentService.cmabConfig) experimentServices = append(experimentServices, experimentCmabService) experimentBucketerService := NewExperimentBucketerService(logging.GetLogger(sdkKey, "ExperimentBucketerService")) diff --git a/pkg/decision/composite_experiment_service_test.go b/pkg/decision/composite_experiment_service_test.go index bf524a96..fa65d3a1 100644 --- a/pkg/decision/composite_experiment_service_test.go +++ b/pkg/decision/composite_experiment_service_test.go @@ -19,10 +19,12 @@ package decision import ( "errors" "testing" + "time" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" + "github.com/optimizely/go-sdk/v2/pkg/cmab" "github.com/optimizely/go-sdk/v2/pkg/decide" "github.com/optimizely/go-sdk/v2/pkg/entities" "github.com/optimizely/go-sdk/v2/pkg/logging" @@ -244,6 +246,128 @@ func (s *CompositeExperimentTestSuite) TestNewCompositeExperimentServiceWithCust s.IsType(&PersistingExperimentService{}, compositeExperimentService.experimentServices[3]) } +func (s *CompositeExperimentTestSuite) TestNewCompositeExperimentServiceWithCmabConfig() { + // Test with custom CMAB config + cmabConfig := cmab.Config{ + CacheSize: 200, + CacheTTL: 5 * time.Minute, + HTTPTimeout: 30 * time.Second, + RetryConfig: &cmab.RetryConfig{ + MaxRetries: 5, + }, + } + + compositeExperimentService := NewCompositeExperimentService("test-sdk-key", + WithCmabConfig(&cmabConfig), + ) + + // Verify CMAB config was set + s.NotNil(compositeExperimentService.cmabConfig) + s.Equal(200, compositeExperimentService.cmabConfig.CacheSize) // From config + s.Equal(5*time.Minute, compositeExperimentService.cmabConfig.CacheTTL) // From config + s.Equal(30*time.Second, compositeExperimentService.cmabConfig.HTTPTimeout) // From config + s.NotNil(compositeExperimentService.cmabConfig.RetryConfig) + s.Equal(5, compositeExperimentService.cmabConfig.RetryConfig.MaxRetries) // From config + + // Verify service order + s.Equal(3, len(compositeExperimentService.experimentServices)) + s.IsType(&ExperimentWhitelistService{}, compositeExperimentService.experimentServices[0]) + s.IsType(&ExperimentCmabService{}, compositeExperimentService.experimentServices[1]) + s.IsType(&ExperimentBucketerService{}, compositeExperimentService.experimentServices[2]) +} + +func (s *CompositeExperimentTestSuite) TestNewCompositeExperimentServiceWithPartialCmabConfig() { + // Test that partial CMAB config is handled properly + partialConfig := cmab.Config{ + CacheSize: 250, // Only set cache size in config + } + compositeExperimentService := NewCompositeExperimentService("test-sdk-key", + WithCmabConfig(&partialConfig), + ) + + // Verify config was set + s.NotNil(compositeExperimentService.cmabConfig) + s.Equal(250, compositeExperimentService.cmabConfig.CacheSize) + + // Test with custom cache + mockCache := &mockCache{} + configWithCache := cmab.Config{ + Cache: mockCache, + } + compositeExperimentService2 := NewCompositeExperimentService("test-sdk-key", + WithCmabConfig(&configWithCache), + ) + s.NotNil(compositeExperimentService2.cmabConfig) + s.NotNil(compositeExperimentService2.cmabConfig.Cache) +} + +func (s *CompositeExperimentTestSuite) TestNewCompositeExperimentServiceWithAllOptions() { + // Test with all options including CMAB config + mockUserProfileService := new(MockUserProfileService) + mockExperimentOverrideStore := new(MapExperimentOverridesStore) + cmabConfig := cmab.Config{ + CacheSize: 100, + CacheTTL: time.Minute, + } + + compositeExperimentService := NewCompositeExperimentService("test-sdk-key", + WithUserProfileService(mockUserProfileService), + WithOverrideStore(mockExperimentOverrideStore), + WithCmabConfig(&cmabConfig), + ) + + // Verify all options were applied + s.Equal(mockUserProfileService, compositeExperimentService.userProfileService) + s.Equal(mockExperimentOverrideStore, compositeExperimentService.overrideStore) + // Verify the config was set + s.NotNil(compositeExperimentService.cmabConfig) + s.Equal(100, compositeExperimentService.cmabConfig.CacheSize) + s.Equal(time.Minute, compositeExperimentService.cmabConfig.CacheTTL) + + // Verify service order with all services + s.Equal(4, len(compositeExperimentService.experimentServices)) + s.IsType(&ExperimentOverrideService{}, compositeExperimentService.experimentServices[0]) + s.IsType(&ExperimentWhitelistService{}, compositeExperimentService.experimentServices[1]) + s.IsType(&ExperimentCmabService{}, compositeExperimentService.experimentServices[2]) + s.IsType(&PersistingExperimentService{}, compositeExperimentService.experimentServices[3]) +} + +func (s *CompositeExperimentTestSuite) TestCmabServiceReturnsError() { + // Test that CMAB service error is properly propagated + mockCmabService := new(MockExperimentDecisionService) + testErr := errors.New("Failed to fetch CMAB data for experiment exp_123") + + mockCmabService.On("GetDecision", mock.Anything, mock.Anything, mock.Anything).Return( + ExperimentDecision{}, + decide.NewDecisionReasons(s.options), + testErr, + ) + + compositeService := &CompositeExperimentService{ + experimentServices: []ExperimentService{mockCmabService}, + logger: logging.GetLogger("", "CompositeExperimentService"), + } + + userContext := entities.UserContext{ID: "test_user"} + decision, reasons, err := compositeService.GetDecision(s.testDecisionContext, userContext, s.options) + + // Error should be returned immediately without trying other services + s.Error(err) + s.Equal(testErr, err) + s.Nil(decision.Variation) + s.NotNil(reasons) + + mockCmabService.AssertExpectations(s.T()) +} + +// mockCache implements cache.CacheWithRemove for testing +type mockCache struct{} + +func (m *mockCache) Save(key string, value interface{}) {} +func (m *mockCache) Lookup(key string) interface{} { return nil } +func (m *mockCache) Reset() {} +func (m *mockCache) Remove(key string) {} + func TestCompositeExperimentTestSuite(t *testing.T) { suite.Run(t, new(CompositeExperimentTestSuite)) } diff --git a/pkg/decision/composite_feature_service.go b/pkg/decision/composite_feature_service.go index 124e9d85..b92ded53 100644 --- a/pkg/decision/composite_feature_service.go +++ b/pkg/decision/composite_feature_service.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2025, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * diff --git a/pkg/decision/composite_feature_service_test.go b/pkg/decision/composite_feature_service_test.go index 1eab3348..412a810c 100644 --- a/pkg/decision/composite_feature_service_test.go +++ b/pkg/decision/composite_feature_service_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2025, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -137,7 +137,6 @@ func (s *CompositeFeatureServiceTestSuite) TestGetDecisionReturnsError() { // Change: Second service should NOT be called when first service returns error s.mockFeatureService2.AssertNotCalled(s.T(), "GetDecision") } - func (s *CompositeFeatureServiceTestSuite) TestGetDecisionReturnsLastDecisionWithError() { // This test is now invalid - rename to reflect new behavior // Test that first error stops evaluation (no "last decision" concept anymore) diff --git a/pkg/decision/evaluator/condition_tree.go b/pkg/decision/evaluator/condition_tree.go index 41ab3681..f9302425 100644 --- a/pkg/decision/evaluator/condition_tree.go +++ b/pkg/decision/evaluator/condition_tree.go @@ -75,14 +75,15 @@ func (c MixedTreeEvaluator) Evaluate(node *entities.TreeNode, condTreeParams *en var result bool var err error - var decisionReasons decide.DecisionReasons switch v := node.Item.(type) { case entities.Condition: evaluator := NewCustomAttributeConditionEvaluator(c.logger) + var decisionReasons decide.DecisionReasons result, decisionReasons, err = evaluator.Evaluate(node.Item.(entities.Condition), condTreeParams, options) reasons.Append(decisionReasons) case string: evaluator := NewAudienceConditionEvaluator(c.logger) + var decisionReasons decide.DecisionReasons result, decisionReasons, err = evaluator.Evaluate(node.Item.(string), condTreeParams, options) reasons.Append(decisionReasons) default: diff --git a/pkg/decision/experiment_cmab_service.go b/pkg/decision/experiment_cmab_service.go index 91138741..53dd9c23 100644 --- a/pkg/decision/experiment_cmab_service.go +++ b/pkg/decision/experiment_cmab_service.go @@ -45,21 +45,86 @@ type ExperimentCmabService struct { } // NewExperimentCmabService creates a new instance of ExperimentCmabService with all dependencies initialized -func NewExperimentCmabService(sdkKey string) *ExperimentCmabService { - // Initialize CMAB cache - cmabCache := cache.NewLRUCache(100, 0) - - // Create retry config for CMAB client - retryConfig := &cmab.RetryConfig{ - MaxRetries: cmab.DefaultMaxRetries, - InitialBackoff: cmab.DefaultInitialBackoff, - MaxBackoff: cmab.DefaultMaxBackoff, - BackoffMultiplier: cmab.DefaultBackoffMultiplier, +func NewExperimentCmabService(sdkKey string, config *cmab.Config) *ExperimentCmabService { + // If config is nil, use all defaults + var cacheSize int + var cacheTTL time.Duration + var httpTimeout time.Duration + var retryConfig *cmab.RetryConfig + var customCache cache.CacheWithRemove + + if config == nil { + // Use all defaults + cacheSize = cmab.DefaultCacheSize + cacheTTL = cmab.DefaultCacheTTL + httpTimeout = cmab.DefaultHTTPTimeout + retryConfig = &cmab.RetryConfig{ + MaxRetries: cmab.DefaultMaxRetries, + InitialBackoff: cmab.DefaultInitialBackoff, + MaxBackoff: cmab.DefaultMaxBackoff, + BackoffMultiplier: cmab.DefaultBackoffMultiplier, + } + } else { + // Config is not nil, use defaults for zero values + customCache = config.Cache + + cacheSize = config.CacheSize + if cacheSize == 0 { + cacheSize = cmab.DefaultCacheSize + } + + cacheTTL = config.CacheTTL + if cacheTTL == 0 { + cacheTTL = cmab.DefaultCacheTTL + } + + httpTimeout = config.HTTPTimeout + if httpTimeout == 0 { + httpTimeout = cmab.DefaultHTTPTimeout + } + + // Handle retry config + if config.RetryConfig == nil { + retryConfig = &cmab.RetryConfig{ + MaxRetries: cmab.DefaultMaxRetries, + InitialBackoff: cmab.DefaultInitialBackoff, + MaxBackoff: cmab.DefaultMaxBackoff, + BackoffMultiplier: cmab.DefaultBackoffMultiplier, + } + } else { + retryConfig = config.RetryConfig + // Apply defaults for zero values in provided RetryConfig + if retryConfig.MaxRetries == 0 { + retryConfig.MaxRetries = cmab.DefaultMaxRetries + } + if retryConfig.InitialBackoff == 0 { + retryConfig.InitialBackoff = cmab.DefaultInitialBackoff + } + if retryConfig.MaxBackoff == 0 { + retryConfig.MaxBackoff = cmab.DefaultMaxBackoff + } + if retryConfig.BackoffMultiplier == 0 { + retryConfig.BackoffMultiplier = cmab.DefaultBackoffMultiplier + } + } + } + + // Use custom cache if provided, otherwise create default LRU cache + var cmabCache cache.CacheWithRemove + if customCache != nil { + cmabCache = customCache + } else { + cmabCache = cache.NewLRUCache(cacheSize, cacheTTL) + } + + // Create HTTP client with config timeout + httpClient := &http.Client{ + Timeout: httpTimeout, } // Create CMAB client options cmabClientOptions := cmab.ClientOptions{ - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + HTTPClient: httpClient, RetryConfig: retryConfig, Logger: logging.GetLogger(sdkKey, "DefaultCmabClient"), } diff --git a/pkg/decision/experiment_cmab_service_test.go b/pkg/decision/experiment_cmab_service_test.go index 28a31676..0ce7d7e6 100644 --- a/pkg/decision/experiment_cmab_service_test.go +++ b/pkg/decision/experiment_cmab_service_test.go @@ -87,7 +87,7 @@ func (s *ExperimentCmabTestSuite) SetupTest() { } // Create service with real dependencies first - s.experimentCmabService = NewExperimentCmabService("test_sdk_key") + s.experimentCmabService = NewExperimentCmabService("test_sdk_key", nil) // inject the mocks s.experimentCmabService.bucketer = s.mockExperimentBucketer @@ -322,7 +322,7 @@ func (s *ExperimentCmabTestSuite) TestGetDecisionWithCmabServiceError() { // Should return the CMAB service error with exact format - updated to match new format s.Error(err) s.Contains(err.Error(), "Failed to fetch CMAB data for experiment") // Updated from "failed" to "Failed" - s.Nil(decision.Variation) // No variation when error occurs + s.Nil(decision.Variation) // No variation when error occurs s.mockExperimentBucketer.AssertExpectations(s.T()) s.mockCmabService.AssertExpectations(s.T()) diff --git a/pkg/decision/feature_experiment_service_test.go b/pkg/decision/feature_experiment_service_test.go index 2df57291..7d1c55c1 100644 --- a/pkg/decision/feature_experiment_service_test.go +++ b/pkg/decision/feature_experiment_service_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2021, Optimizely, Inc. and contributors * + * Copyright 2019-2025, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -284,9 +284,9 @@ func (s *FeatureExperimentServiceTestSuite) TestGetDecisionWithCmabError() { // Call GetDecision actualFeatureDecision, actualReasons, err := featureExperimentService.GetDecision(testFeatureDecisionContextWithCmab, testUserContext, s.options) - // CMAB errors should result in empty feature decision with the error returned - s.Error(err, "CMAB errors should be returned as errors") // ← Changed from s.NoError - s.Contains(err.Error(), "Failed to fetch CMAB data", "Error should contain CMAB failure message") + // Verify that CMAB error is propagated (UPDATE THIS) + s.Error(err, "CMAB errors should be propagated to prevent rollout fallback") + s.Contains(err.Error(), "Failed to fetch CMAB data for experiment cmab_experiment_key") s.Equal(FeatureDecision{}, actualFeatureDecision, "Should return empty FeatureDecision when CMAB fails") // Verify that reasons include the CMAB error