Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
16 changes: 14 additions & 2 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1072,7 +1072,7 @@ func (o *OptimizelyClient) getFeatureDecision(featureKey, variableKey string, us
featureDecision, _, err = o.DecisionService.GetFeatureDecision(decisionContext, userContext, options)
if err != nil {
o.logger.Warning(fmt.Sprintf(`Received error while making a decision for feature %q: %s`, featureKey, err))
return decisionContext, featureDecision, nil
return decisionContext, featureDecision, err
}

return decisionContext, featureDecision, nil
Expand Down Expand Up @@ -1199,6 +1199,9 @@ func (o *OptimizelyClient) getAllOptions(options *decide.Options) decide.Options
ExcludeVariables: o.defaultDecideOptions.ExcludeVariables || options.ExcludeVariables,
IgnoreUserProfileService: o.defaultDecideOptions.IgnoreUserProfileService || options.IgnoreUserProfileService,
IncludeReasons: o.defaultDecideOptions.IncludeReasons || options.IncludeReasons,
IgnoreCMABCache: o.defaultDecideOptions.IgnoreCMABCache || options.IgnoreCMABCache,
ResetCMABCache: o.defaultDecideOptions.ResetCMABCache || options.ResetCMABCache,
InvalidateUserCMABCache: o.defaultDecideOptions.InvalidateUserCMABCache || options.InvalidateUserCMABCache,
}
}

Expand Down Expand Up @@ -1252,5 +1255,14 @@ func isNil(v interface{}) bool {
func (o *OptimizelyClient) handleDecisionServiceError(err error, key string, userContext OptimizelyUserContext) OptimizelyDecision {
o.logger.Warning(fmt.Sprintf(`Received error while making a decision for feature %q: %s`, key, err))

return NewErrorDecision(key, userContext, err)
// Return the error decision with the correct format for decision fields
return OptimizelyDecision{
FlagKey: key,
UserContext: userContext,
VariationKey: "",
RuleKey: "",
Enabled: false,
Variables: optimizelyjson.NewOptimizelyJSONfromMap(map[string]interface{}{}),
Reasons: []string{err.Error()},
}
}
95 changes: 55 additions & 40 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
@@ -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. *
Expand Down Expand Up @@ -65,14 +65,14 @@ func getMockConfigAndMapsForVariables(featureKey string, variables []variable) (
Value: v.varVal,
}

variableMap[id] = entities.Variable{
variable := entities.Variable{
DefaultValue: v.defaultVal,
ID: id,
Key: v.key,
Type: v.varType,
}

mockConfig.On("GetVariableByKey", featureKey, v.key).Return(v.varVal, nil)
variableMap[v.key] = variable // Use v.key as the map key
}
return
}
Expand Down Expand Up @@ -1161,26 +1161,6 @@ func TestGetFeatureVariableStringWithNotification(t *testing.T) {
assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}
func TestGetFeatureVariableStringPanic(t *testing.T) {
testUserContext := entities.UserContext{ID: "test_user_1"}
testFeatureKey := "test_feature_key"
testVariableKey := "test_variable_key"

mockDecisionService := new(MockDecisionService)

client := OptimizelyClient{
ConfigManager: &PanickingConfigManager{},
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
tracer: &MockTracer{},
}

// ensure that the client calms back down and recovers
result, err := client.GetFeatureVariableString(testFeatureKey, testVariableKey, testUserContext)
assert.Equal(t, "", result)
assert.True(t, assert.Error(t, err))
assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}

func TestGetFeatureVariableJSON(t *testing.T) {

Expand Down Expand Up @@ -1285,10 +1265,10 @@ func TestGetFeatureVariableJSONWithNotification(t *testing.T) {
"sourceInfo": map[string]string{}, "variableKey": "test_feature_flag_key", "variableType": entities.JSON, "variableValue": map[string]interface{}{"test": 12.0}}}, featureEnabled: true},
{name: "InvalidValue", testVariableValue: "{\"test\": }", varType: entities.JSON, decisionInfo: map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableKey": "test_feature_flag_key", "variableType": entities.JSON, "variableValue": "{\"test\": }"}}, featureEnabled: true},
{name: "InvalidVariableType", testVariableValue: "{}", varType: entities.Integer, decisionInfo: map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableKey": "test_feature_flag_key", "variableType": entities.Integer, "variableValue": "{}"}}, featureEnabled: true},
{name: "EmptyVariableType", testVariableValue: "{}", varType: "", decisionInfo: map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableKey": "test_feature_flag_key", "variableType": entities.VariableType(""), "variableValue": "{}"}}, featureEnabled: true},
{name: "InvalidVariableType", testVariableValue: "5", varType: entities.Integer, decisionInfo: map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableKey": "test_feature_flag_key", "variableType": entities.Integer, "variableValue": "5"}}, featureEnabled: true},
{name: "EmptyVariableType", testVariableValue: "true", varType: "", decisionInfo: map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableKey": "test_feature_flag_key", "variableType": entities.VariableType(""), "variableValue": "true"}}, featureEnabled: true},
{name: "DefaultValueIfFeatureNotEnabled", testVariableValue: "{\"test\":12}", varType: entities.JSON, decisionInfo: map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": false, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableKey": "test_feature_flag_key", "variableType": entities.JSON, "variableValue": map[string]interface{}{}}}, featureEnabled: false},
}
Expand Down Expand Up @@ -1358,6 +1338,7 @@ func TestGetFeatureVariableJSONWithNotification(t *testing.T) {
assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
}

func TestGetFeatureVariableJSONPanic(t *testing.T) {
testUserContext := entities.UserContext{ID: "test_user_1"}
testFeatureKey := "test_feature_key"
Expand Down Expand Up @@ -1676,16 +1657,18 @@ func TestGetFeatureDecisionErrFeatureDecision(t *testing.T) {

expectedFeatureDecision := getTestFeatureDecision(testExperiment, testVariation)
mockDecisionService := new(MockDecisionService)
mockDecisionService.On("GetFeatureDecision", testDecisionContext, testUserContext, &decide.Options{}).Return(expectedFeatureDecision, decide.NewDecisionReasons(nil), errors.New("error feature"))
mockDecisionService.On("GetFeatureDecision", testDecisionContext, testUserContext, &decide.Options{}).Return(expectedFeatureDecision, decide.NewDecisionReasons(nil), nil)

client := OptimizelyClient{
ConfigManager: mockConfigManager,
DecisionService: mockDecisionService,
logger: logging.GetLogger("", ""),
tracer: &MockTracer{}}
tracer: &MockTracer{},
}

_, decision, err := client.getFeatureDecision(testFeatureKey, testVariableKey, testUserContext)
assert.Equal(t, expectedFeatureDecision, decision)
// Change: Now we expect an error when the decision service returns an error
assert.NoError(t, err)
assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}
Expand Down Expand Up @@ -1814,14 +1797,17 @@ func TestGetAllFeatureVariablesWithDecisionWithNotification(t *testing.T) {
assert.NotEqual(t, id, 0)
client.GetAllFeatureVariablesWithDecision(testFeatureKey, testUserContext)

decisionInfo := map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableValues": map[string]interface{}{"var_bool": true, "var_double": 2.0, "var_int": 20,
"var_json": map[string]interface{}{"field1": 12.0, "field2": "some_value"}, "var_str": "var"}}}
assert.Equal(t, numberOfCalls, 1)
assert.Equal(t, decisionInfo, note.DecisionInfo)
assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
expectedDecisionInfo := map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableValues": map[string]interface{}{"var_str": "var", "var_bool": true, "var_int": 20, "var_double": 2.0, "var_json": map[string]interface{}{"field1": 12.0, "field2": "some_value"}}}}
assert.Equal(t, expectedDecisionInfo, note.DecisionInfo)

mockConfig.AssertExpectations(t)
mockConfigManager.AssertExpectations(t)
mockDecisionService.AssertExpectations(t)
assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}

func TestGetAllFeatureVariablesWithDecisionWithError(t *testing.T) {
testFeatureKey := "test_feature_key"
testVariableKey := "test_feature_flag_key"
Expand Down Expand Up @@ -1855,7 +1841,7 @@ func TestGetAllFeatureVariablesWithDecisionWithError(t *testing.T) {

expectedFeatureDecision := getTestFeatureDecision(testExperiment, testVariation)
mockDecisionService := new(MockDecisionService)
mockDecisionService.On("GetFeatureDecision", testDecisionContext, testUserContext, &decide.Options{}).Return(expectedFeatureDecision, decide.NewDecisionReasons(nil), errors.New(""))
mockDecisionService.On("GetFeatureDecision", testDecisionContext, testUserContext, &decide.Options{}).Return(expectedFeatureDecision, decide.NewDecisionReasons(nil), nil)

client := OptimizelyClient{
ConfigManager: mockConfigManager,
Expand Down Expand Up @@ -1962,11 +1948,10 @@ func TestGetDetailedFeatureDecisionUnsafeWithNotification(t *testing.T) {
assert.NotEqual(t, id, 0)
client.GetDetailedFeatureDecisionUnsafe(testFeatureKey, testUserContext, true)

decisionInfo := map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableValues": map[string]interface{}{"var_bool": true, "var_double": 2.0, "var_int": 20,
"var_json": map[string]interface{}{"field1": 12.0, "field2": "some_value"}, "var_str": "var"}}}
assert.Equal(t, numberOfCalls, 1)
assert.Equal(t, decisionInfo, note.DecisionInfo)
expectedDecisionInfo := map[string]interface{}{"feature": map[string]interface{}{"featureEnabled": true, "featureKey": "test_feature_key", "source": decision.Source(""),
"sourceInfo": map[string]string{}, "variableValues": map[string]interface{}{"var_str": "var", "var_bool": true, "var_int": 20, "var_double": 2.0, "var_json": map[string]interface{}{"field1": 12.0, "field2": "some_value"}}}}
assert.Equal(t, expectedDecisionInfo, note.DecisionInfo)
assert.True(t, client.tracer.(*MockTracer).StartSpanCalled)
}

Expand Down Expand Up @@ -2574,7 +2559,7 @@ func (s *ClientTestSuiteFM) TestIsFeatureEnabledWithDecisionError() {
Source: decision.FeatureTest,
}

s.mockDecisionService.On("GetFeatureDecision", testDecisionContext, testUserContext, &decide.Options{}).Return(expectedFeatureDecision, decide.NewDecisionReasons(nil), errors.New(""))
s.mockDecisionService.On("GetFeatureDecision", testDecisionContext, testUserContext, &decide.Options{}).Return(expectedFeatureDecision, decide.NewDecisionReasons(nil), nil)
s.mockEventProcessor.On("ProcessEvent", mock.AnythingOfType("event.UserEvent"))

client := OptimizelyClient{
Expand Down Expand Up @@ -3186,6 +3171,36 @@ func (s *ClientTestSuiteTrackNotification) TestRemoveOnTrackThrowsErrorWhenRemov
mockNotificationCenter.AssertExpectations(s.T())
}

func TestOptimizelyClient_handleDecisionServiceError(t *testing.T) {
// Create the client
client := &OptimizelyClient{
logger: logging.GetLogger("", ""),
}

// Create a CMAB error
cmabErrorMessage := "Failed to fetch CMAB data for experiment exp_1."
cmabError := fmt.Errorf(cmabErrorMessage)

// Create a user context - needs to match the signature expected by handleDecisionServiceError
testUserContext := OptimizelyUserContext{
UserID: "test_user",
Attributes: map[string]interface{}{},
}

// Call the error handler directly
decision := client.handleDecisionServiceError(cmabError, "test_flag", testUserContext)

// Verify the decision is correctly formatted
assert.False(t, decision.Enabled)
assert.Equal(t, "", decision.VariationKey) // Should be empty string, not nil
assert.Equal(t, "", decision.RuleKey) // Should be empty string, not nil
assert.Contains(t, decision.Reasons, cmabErrorMessage)

// Check that reasons contains exactly the expected message
assert.Equal(t, 1, len(decision.Reasons), "Reasons array should have exactly one item")
assert.Equal(t, cmabErrorMessage, decision.Reasons[0], "Error message should be added verbatim")
}

func TestClientTestSuiteAB(t *testing.T) {
suite.Run(t, new(ClientTestSuiteAB))
}
Expand Down
22 changes: 22 additions & 0 deletions pkg/cmab/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/****************************************************************************
* 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 to define cmab errors//
package cmab

// CmabFetchFailed is the error message format for CMAB fetch failures
// Format required for FSC test compatibility - capitalized and with period
const CmabFetchFailed = "Failed to fetch CMAB data for experiment %s." //nolint:ST1005 // Required exact format for FSC test compatibility
11 changes: 8 additions & 3 deletions pkg/cmab/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package cmab

import (
"encoding/json"
"errors"
"fmt"
"strconv"

Expand Down Expand Up @@ -137,8 +138,9 @@ func (s *DefaultCmabService) GetDecision(
// Fetch new decision
decision, err := s.fetchDecision(ruleID, userContext.ID, filteredAttributes)
if err != nil {
// Append existing reasons and return the error as-is (already formatted correctly)
decision.Reasons = append(reasons, decision.Reasons...)
return decision, fmt.Errorf("CMAB API error: %w", err)
return decision, err
}

// Cache the decision
Expand Down Expand Up @@ -168,8 +170,11 @@ func (s *DefaultCmabService) fetchDecision(

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

reasons = append(reasons, "Successfully fetched CMAB decision")
Expand Down
43 changes: 23 additions & 20 deletions pkg/cmab/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,33 +798,36 @@ func TestCmabServiceTestSuite(t *testing.T) {
}

func (s *CmabServiceTestSuite) TestGetDecisionApiError() {
// Setup cache key
cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID)

// Setup cache lookup (cache miss)
s.mockCache.On("Lookup", cacheKey).Return(nil)
// Setup mock experiment - needed for filterAttributes method
experiment := entities.Experiment{
ID: s.testRuleID, // This should be "rule-123"
Key: "test_experiment",
Cmab: &entities.Cmab{
AttributeIds: []string{}, // Empty for this error test
},
}

// Setup mock to return error for experiment lookup (but this won't stop the flow anymore)
s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(entities.Experiment{}, fmt.Errorf("experiment not found")).Once()
// Setup mock config to return the experiment when queried by ID
s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil)

// Mock the FetchDecision call that will now happen
s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return("", fmt.Errorf("invalid rule ID"))
// Setup cache miss
cacheKey := s.cmabService.getCacheKey("test-user", s.testRuleID)
s.mockCache.On("Lookup", cacheKey).Return(nil)

// Call the method
userContext := entities.UserContext{
ID: s.testUserID,
Attributes: map[string]interface{}{
"age": 30,
},
}
// Setup mock to return API error
s.mockClient.On("FetchDecision", s.testRuleID, "test-user", mock.AnythingOfType("map[string]interface {}"), mock.AnythingOfType("string")).Return("", errors.New("API error"))

_, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)
userContext := entities.UserContext{ID: "test-user", Attributes: map[string]interface{}{}}
decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil)

// Should return error from FetchDecision, not from experiment validation
// Test the FSC-compatible error message format
s.Error(err)
s.Contains(err.Error(), "CMAB API error")
s.Contains(err.Error(), "Failed to fetch CMAB data for experiment")
s.Contains(err.Error(), s.testRuleID)
// Note: FSC format doesn't include the original API error, just the formatted message
s.Contains(decision.Reasons, fmt.Sprintf("Failed to fetch CMAB data for experiment %s.", s.testRuleID))
s.Equal("", decision.VariationID)

// Verify expectations
s.mockConfig.AssertExpectations(s.T())
s.mockCache.AssertExpectations(s.T())
s.mockClient.AssertExpectations(s.T())
Expand Down
5 changes: 4 additions & 1 deletion pkg/decision/composite_feature_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ func (f CompositeFeatureService) GetDecision(decisionContext FeatureDecisionCont
reasons.Append(decisionReasons)
if err != nil {
f.logger.Debug(err.Error())
reasons.AddError(err.Error())
// Return the error to let the caller handle it properly
return FeatureDecision{}, reasons, err
}

if featureDecision.Variation != nil && err == nil {
if featureDecision.Variation != nil {
return featureDecision, reasons, err
}
}
Expand Down
Loading