Skip to content

Commit 9d57add

Browse files
committed
merge in mpirnovar-fsc-failures-fix-fssdk-11649
2 parents 40aef5e + 2c913ba commit 9d57add

11 files changed

+233
-187
lines changed

pkg/client/client.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1295,5 +1295,14 @@ func isNil(v interface{}) bool {
12951295
func (o *OptimizelyClient) handleDecisionServiceError(err error, key string, userContext OptimizelyUserContext) OptimizelyDecision {
12961296
o.logger.Warning(fmt.Sprintf(`Received error while making a decision for feature %q: %s`, key, err))
12971297

1298-
return NewErrorDecision(key, userContext, err)
1298+
// Return the error decision with the correct format for decision fields
1299+
return OptimizelyDecision{
1300+
FlagKey: key,
1301+
UserContext: userContext,
1302+
VariationKey: "",
1303+
RuleKey: "",
1304+
Enabled: false,
1305+
Variables: optimizelyjson.NewOptimizelyJSONfromMap(map[string]interface{}{}),
1306+
Reasons: []string{err.Error()},
1307+
}
12991308
}

pkg/client/client_test.go

Lines changed: 21 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import (
2929
"github.com/stretchr/testify/mock"
3030
"github.com/stretchr/testify/suite"
3131

32-
"github.com/optimizely/go-sdk/v2/pkg/cmab"
3332
"github.com/optimizely/go-sdk/v2/pkg/config"
3433
"github.com/optimizely/go-sdk/v2/pkg/decide"
3534
"github.com/optimizely/go-sdk/v2/pkg/decision"
@@ -3187,153 +3186,34 @@ func (s *ClientTestSuiteTrackNotification) TestRemoveOnTrackThrowsErrorWhenRemov
31873186
mockNotificationCenter.AssertExpectations(s.T())
31883187
}
31893188

3190-
// MockCmabService for testing CMAB functionality
3191-
type MockCmabService struct {
3192-
mock.Mock
3193-
}
3194-
3195-
// GetDecision safely implements the cmab.Service interface
3196-
func (m *MockCmabService) GetDecision(projectConfig config.ProjectConfig, userContext entities.UserContext, ruleID string, options *decide.Options) (cmab.Decision, error) {
3197-
args := m.Called(projectConfig, userContext, ruleID, options)
3198-
3199-
// IMPORTANT: Return a valid Decision struct with non-nil Reasons slice
3200-
decision, ok := args.Get(0).(cmab.Decision)
3201-
if !ok {
3202-
// If conversion fails, return a safe default
3203-
return cmab.Decision{Reasons: []string{"Mock conversion failed"}}, args.Error(1)
3189+
func TestOptimizelyClient_handleDecisionServiceError(t *testing.T) {
3190+
// Create the client
3191+
client := &OptimizelyClient{
3192+
logger: logging.GetLogger("", ""),
32043193
}
32053194

3206-
// Make sure Reasons is never nil
3207-
if decision.Reasons == nil {
3208-
decision.Reasons = []string{}
3209-
}
3210-
3211-
return decision, args.Error(1)
3212-
}
3195+
// Create a CMAB error
3196+
cmabErrorMessage := "Failed to fetch CMAB data for experiment exp_1."
3197+
cmabError := fmt.Errorf(cmabErrorMessage)
32133198

3214-
func TestDecide_CmabSuccess(t *testing.T) {
3215-
// Use the existing Mock types
3216-
mockConfig := new(MockProjectConfig)
3217-
mockConfigManager := new(MockProjectConfigManager)
3218-
mockEventProcessor := new(MockProcessor)
3219-
mockCmabService := new(MockCmabService)
3220-
mockDecisionService := new(MockDecisionService)
3221-
mockNotificationCenter := new(MockNotificationCenter)
3222-
3223-
// Test data
3224-
featureKey := "test_feature"
3225-
experimentID := "exp_1"
3226-
variationID := "var_1"
3227-
3228-
// Create feature with experiment IDs
3229-
testFeature := entities.Feature{
3230-
Key: featureKey,
3231-
ExperimentIDs: []string{experimentID},
3199+
// Create a user context - needs to match the signature expected by handleDecisionServiceError
3200+
testUserContext := OptimizelyUserContext{
3201+
UserID: "test_user",
3202+
Attributes: map[string]interface{}{},
32323203
}
32333204

3234-
// Create variation
3235-
testVariation := entities.Variation{
3236-
ID: variationID,
3237-
Key: "variation_1",
3238-
FeatureEnabled: true,
3239-
}
3240-
3241-
// Create experiment with CMAB data
3242-
testExperiment := entities.Experiment{
3243-
ID: experimentID,
3244-
Key: "exp_key",
3245-
Cmab: &entities.Cmab{
3246-
TrafficAllocation: 10000,
3247-
},
3248-
Variations: map[string]entities.Variation{
3249-
variationID: testVariation,
3250-
},
3251-
}
3252-
3253-
// Mock GetConfig call
3254-
mockConfigManager.On("GetConfig").Return(mockConfig, nil)
3255-
3256-
// Log and track calls to GetExperimentByID
3257-
experimentCalls := make([]string, 0)
3258-
mockConfig.On("GetExperimentByID", mock.Anything).Return(testExperiment, nil).Run(
3259-
func(args mock.Arguments) {
3260-
id := args.Get(0).(string)
3261-
experimentCalls = append(experimentCalls, id)
3262-
t.Logf("GetExperimentByID called with: %s", id)
3263-
})
3264-
3265-
// Mock GetFeatureByKey
3266-
mockConfig.On("GetFeatureByKey", featureKey).Return(testFeature, nil)
3267-
3268-
// Track calls to CMAB service
3269-
cmabCalls := make([]string, 0)
3270-
mockCmabService.On("GetDecision", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
3271-
Return(cmab.Decision{VariationID: variationID, CmabUUID: "uuid"}, nil).
3272-
Run(func(args mock.Arguments) {
3273-
id := args.Get(2).(string)
3274-
cmabCalls = append(cmabCalls, id)
3275-
t.Logf("GetDecision called with id: %s", id)
3276-
})
3277-
3278-
// Mock event processor
3279-
mockEventProcessor.On("ProcessEvent", mock.Anything).Return(true)
3280-
3281-
// Mock notification center
3282-
mockNotificationCenter.On("Send", notification.Decision, mock.Anything).Return(nil)
3283-
3284-
// Let's add every field to client to be sure
3285-
client := OptimizelyClient{
3286-
ConfigManager: mockConfigManager,
3287-
DecisionService: mockDecisionService,
3288-
EventProcessor: mockEventProcessor,
3289-
notificationCenter: mockNotificationCenter,
3290-
cmabService: mockCmabService,
3291-
logger: logging.GetLogger("debug", "TestCMAB"),
3292-
ctx: context.Background(),
3293-
tracer: &MockTracer{},
3294-
defaultDecideOptions: &decide.Options{},
3295-
}
3296-
3297-
// Create user context
3298-
userContext := client.CreateUserContext("test_user", nil)
3299-
3300-
// Wrap the call in a panic handler
3301-
var decision OptimizelyDecision
3302-
var panicOccurred bool
3303-
var panicValue interface{}
3304-
3305-
func() {
3306-
defer func() {
3307-
if r := recover(); r != nil {
3308-
panicOccurred = true
3309-
panicValue = r
3310-
t.Logf("Panic occurred: %v", r)
3311-
}
3312-
}()
3313-
decision = client.decide(&userContext, featureKey, nil)
3314-
}()
3315-
3316-
t.Logf("Panic occurred: %v", panicOccurred)
3317-
if panicOccurred {
3318-
t.Logf("Panic value: %v", panicValue)
3319-
}
3320-
t.Logf("GetExperimentByID calls: %v", experimentCalls)
3321-
t.Logf("GetDecision calls: %v", cmabCalls)
3322-
t.Logf("Decision: %+v", decision)
3205+
// Call the error handler directly
3206+
decision := client.handleDecisionServiceError(cmabError, "test_flag", testUserContext)
33233207

3324-
// Skip further assertions if we panicked
3325-
if panicOccurred {
3326-
t.Log("Test skipping assertions due to panic")
3327-
return
3328-
}
3208+
// Verify the decision is correctly formatted
3209+
assert.False(t, decision.Enabled)
3210+
assert.Equal(t, "", decision.VariationKey) // Should be empty string, not nil
3211+
assert.Equal(t, "", decision.RuleKey) // Should be empty string, not nil
3212+
assert.Contains(t, decision.Reasons, cmabErrorMessage)
33293213

3330-
// Basic assertions on the decision
3331-
if len(cmabCalls) > 0 {
3332-
assert.Equal(t, featureKey, decision.FlagKey)
3333-
assert.Equal(t, "variation_1", decision.VariationKey)
3334-
assert.Equal(t, "exp_key", decision.RuleKey)
3335-
assert.True(t, decision.Enabled)
3336-
}
3214+
// Check that reasons contains exactly the expected message
3215+
assert.Equal(t, 1, len(decision.Reasons), "Reasons array should have exactly one item")
3216+
assert.Equal(t, cmabErrorMessage, decision.Reasons[0], "Error message should be added verbatim")
33373217
}
33383218

33393219
func TestClientTestSuiteAB(t *testing.T) {

pkg/cmab/errors.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/****************************************************************************
2+
* Copyright 2025, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package cmab to define cmab errors//
18+
package cmab
19+
20+
// CmabFetchFailed is the error message format for CMAB fetch failures
21+
// Format required for FSC test compatibility - capitalized and with period
22+
const CmabFetchFailed = "Failed to fetch CMAB data for experiment %s." //nolint:ST1005 // Required exact format for FSC test compatibility

pkg/cmab/service.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package cmab
1919

2020
import (
2121
"encoding/json"
22+
"errors"
2223
"fmt"
2324
"strconv"
2425

@@ -137,8 +138,9 @@ func (s *DefaultCmabService) GetDecision(
137138
// Fetch new decision
138139
decision, err := s.fetchDecision(ruleID, userContext.ID, filteredAttributes)
139140
if err != nil {
140-
// properly propagate error reasons in Decision object
141-
return Decision{Reasons: reasons}, fmt.Errorf("CMAB API error: %w", err)
141+
// Append existing reasons and return the error as-is (already formatted correctly)
142+
decision.Reasons = append(reasons, decision.Reasons...)
143+
return decision, err
142144
}
143145

144146
// Cache the decision
@@ -168,8 +170,11 @@ func (s *DefaultCmabService) fetchDecision(
168170

169171
variationID, err := s.cmabClient.FetchDecision(ruleID, userID, attributes, cmabUUID)
170172
if err != nil {
171-
reasons = append(reasons, "Failed to fetch CMAB decision")
172-
return Decision{Reasons: reasons}, fmt.Errorf("CMAB API error: %w", err)
173+
// Use the consistent error message format from errors.go
174+
reason := fmt.Sprintf(CmabFetchFailed, ruleID)
175+
reasons = append(reasons, reason)
176+
// Use same format for Go error - FSC compatibility takes precedence
177+
return Decision{Reasons: reasons}, errors.New(reason) //nolint:ST1005 // Required exact format for FSC test compatibility
173178
}
174179

175180
reasons = append(reasons, "Successfully fetched CMAB decision")

pkg/cmab/service_test.go

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -853,34 +853,38 @@ func TestCmabServiceTestSuite(t *testing.T) {
853853
}
854854

855855
func (s *CmabServiceTestSuite) TestGetDecisionApiError() {
856-
// Setup cache key
857-
cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)
858-
859-
// Setup cache lookup (cache miss)
860-
s.mockCache.On("Lookup", cacheKey).Return(nil)
861-
862-
// Setup mock to return error for experiment lookup (but this won't stop the flow anymore)
863-
s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(entities.Experiment{}, fmt.Errorf("experiment not found")).Once()
864-
865-
// Mock the FetchDecision call that will now happen
866-
s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return("", fmt.Errorf("invalid rule ID"))
867-
868-
// Call the method
869-
userContext := entities.UserContext{
870-
ID: s.testUserID,
871-
Attributes: map[string]interface{}{
872-
"age": 30,
873-
},
874-
}
875-
876-
_, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)
877-
878-
// Should return error from FetchDecision, not from experiment validation
879-
s.Error(err)
880-
s.Contains(err.Error(), "CMAB API error")
881-
882-
// Verify expectations
883-
s.mockConfig.AssertExpectations(s.T())
884-
s.mockCache.AssertExpectations(s.T())
885-
s.mockClient.AssertExpectations(s.T())
856+
// Setup experiment with CMAB config
857+
experiment := entities.Experiment{
858+
ID: "rule-123",
859+
Cmab: &entities.Cmab{
860+
AttributeIds: []string{"attr1"},
861+
},
862+
}
863+
s.mockConfig.On("GetExperimentByID", "rule-123").Return(experiment, nil)
864+
s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("category", nil)
865+
866+
// Configure client to return error
867+
s.mockClient.On("FetchDecision", "rule-123", s.testUserID, mock.Anything, mock.Anything).Return("", errors.New("API error"))
868+
869+
// Setup cache miss
870+
cacheKey := s.cmabService.getCacheKey(s.testUserID, "rule-123")
871+
s.mockCache.On("Lookup", cacheKey).Return(nil)
872+
873+
userContext := entities.UserContext{
874+
ID: s.testUserID,
875+
Attributes: map[string]interface{}{
876+
"category": "cmab",
877+
},
878+
}
879+
880+
decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, "rule-123", nil)
881+
882+
// Should return the exact error format expected by FSC tests
883+
s.Error(err)
884+
s.Contains(err.Error(), "Failed to fetch CMAB data for experiment") // Updated expectation
885+
s.Contains(decision.Reasons, "Failed to fetch CMAB data for experiment rule-123.")
886+
887+
s.mockConfig.AssertExpectations(s.T())
888+
s.mockCache.AssertExpectations(s.T())
889+
s.mockClient.AssertExpectations(s.T())
886890
}

pkg/decision/composite_feature_service.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
package decision
1919

2020
import (
21+
"strings"
22+
2123
"github.com/optimizely/go-sdk/v2/pkg/decide"
2224
"github.com/optimizely/go-sdk/v2/pkg/entities"
2325
"github.com/optimizely/go-sdk/v2/pkg/logging"
@@ -51,6 +53,21 @@ func (f CompositeFeatureService) GetDecision(decisionContext FeatureDecisionCont
5153
reasons.Append(decisionReasons)
5254
if err != nil {
5355
f.logger.Debug(err.Error())
56+
// Check if this is a CMAB error - if so, stop the loop and return empty decision
57+
if strings.Contains(err.Error(), "Failed to fetch CMAB data") {
58+
// Add the CMAB error to reasons before returning - use AddError for critical failures
59+
reasons.AddError(err.Error())
60+
return FeatureDecision{}, reasons, nil // Return empty decision for CMAB errors
61+
}
62+
}
63+
64+
// Also check for CMAB errors in decision reasons (when err is nil)
65+
if decisionReasons != nil {
66+
for _, reason := range decisionReasons.ToReport() {
67+
if strings.Contains(reason, "Failed to fetch CMAB data") {
68+
return FeatureDecision{}, reasons, nil
69+
}
70+
}
5471
}
5572

5673
if featureDecision.Variation != nil && err == nil {

0 commit comments

Comments
 (0)