Skip to content
Open
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
10 changes: 10 additions & 0 deletions optimizely/decision_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,16 @@ def get_decision_for_flag(
experiment = project_config.get_experiment_from_id(experiment_id)

if experiment:
# Skip experiments with unsupported types.
# If the experiment type is None (not set in datafile), we still evaluate it.
# If the experiment type is set but not in the supported list, we skip it.
if experiment.type is not None and experiment.type not in entities.ExperimentTypes.SUPPORTED_TYPES:
self.logger.debug(
f'Skipping experiment "{experiment.key}" with unsupported type '
f'"{experiment.type}" for feature "{feature_flag.key}".'
)
continue

# Check for forced decision
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
feature_flag.key, experiment.key)
Expand Down
18 changes: 17 additions & 1 deletion optimizely/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional, Sequence
from typing import TYPE_CHECKING, Any, Final, Optional, Sequence
from sys import version_info

if version_info < (3, 8):
Expand Down Expand Up @@ -72,6 +72,20 @@ def __init__(self, id: str, key: str, experimentIds: list[str], **kwargs: Any):
self.experimentIds = experimentIds


class ExperimentTypes:
"""Supported experiment types recognized by the SDK.

Experiments with a type not in SUPPORTED_TYPES will be skipped during flag decisions.
If an experiment has no type (None), it is still evaluated.
"""
AB = 'a/b'
MAB = 'mab'
CMAB = 'cmab'
FEATURE_ROLLOUTS = 'feature_rollouts'

SUPPORTED_TYPES: Final = frozenset({AB, MAB, CMAB, FEATURE_ROLLOUTS})


class Experiment(BaseEntity):
def __init__(
self,
Expand All @@ -87,6 +101,7 @@ def __init__(
groupId: Optional[str] = None,
groupPolicy: Optional[str] = None,
cmab: Optional[CmabDict] = None,
type: Optional[str] = None,
**kwargs: Any
):
self.id = id
Expand All @@ -101,6 +116,7 @@ def __init__(
self.groupId = groupId
self.groupPolicy = groupPolicy
self.cmab = cmab
self.type = type

def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
""" Returns audienceConditions if present, otherwise audienceIds. """
Expand Down
99 changes: 99 additions & 0 deletions tests/test_decision_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2008,3 +2008,102 @@ def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_25
mock_config_logging.debug.assert_called_with(
'Assigned bucket 4000 to user with bucketing ID "test_user".')
mock_generate_bucket_value.assert_called_with("test_user211147")

def test_get_decision_for_flag_skips_unsupported_experiment_type(self):
"""Test that experiments with unsupported types are skipped during flag decisions."""

user = optimizely_user_context.OptimizelyUserContext(
optimizely_client=None, logger=None, user_id="test_user", user_attributes={}
)
feature = self.project_config.get_feature_from_key("test_feature_in_experiment")

# Get the experiment and set an unsupported type
experiment = self.project_config.get_experiment_from_key("test_experiment")
original_type = experiment.type
experiment.type = "unsupported_type"

try:
with mock.patch(
"optimizely.decision_service.DecisionService.get_variation"
) as mock_get_variation:
result = self.decision_service.get_variation_for_feature(
self.project_config, feature, user, options=None
)
# get_variation should NOT have been called since the experiment type is unsupported
mock_get_variation.assert_not_called()
finally:
experiment.type = original_type

def test_get_decision_for_flag_evaluates_experiment_with_none_type(self):
"""Test that experiments with None type (not set in datafile) are still evaluated."""

user = optimizely_user_context.OptimizelyUserContext(
optimizely_client=None, logger=None, user_id="test_user", user_attributes={}
)
feature = self.project_config.get_feature_from_key("test_feature_in_experiment")

# Make sure the experiment type is None
experiment = self.project_config.get_experiment_from_key("test_experiment")
original_type = experiment.type
experiment.type = None

expected_variation = self.project_config.get_variation_from_id("test_experiment", "111129")
try:
with mock.patch(
"optimizely.decision_service.DecisionService.get_variation",
return_value={'variation': expected_variation, 'cmab_uuid': None, 'reasons': [], 'error': False},
) as mock_get_variation:
result = self.decision_service.get_variation_for_feature(
self.project_config, feature, user, options=None
)
# get_variation SHOULD be called since the experiment type is None
mock_get_variation.assert_called_once()
finally:
experiment.type = original_type

def test_get_decision_for_flag_evaluates_supported_experiment_types(self):
"""Test that experiments with supported types are evaluated."""

user = optimizely_user_context.OptimizelyUserContext(
optimizely_client=None, logger=None, user_id="test_user", user_attributes={}
)
feature = self.project_config.get_feature_from_key("test_feature_in_experiment")
experiment = self.project_config.get_experiment_from_key("test_experiment")
expected_variation = self.project_config.get_variation_from_id("test_experiment", "111129")
original_type = experiment.type

for exp_type in entities.ExperimentTypes.SUPPORTED_TYPES:
experiment.type = exp_type
with mock.patch(
"optimizely.decision_service.DecisionService.get_variation",
return_value={'variation': expected_variation, 'cmab_uuid': None, 'reasons': [], 'error': False},
) as mock_get_variation:
result = self.decision_service.get_variation_for_feature(
self.project_config, feature, user, options=None
)
mock_get_variation.assert_called_once()

experiment.type = original_type

def test_experiment_type_field_parsed_from_datafile(self):
"""Test that the type field is correctly parsed when constructing Experiment entities."""
# Test with type set
exp_with_type = entities.Experiment(
id="123", key="test", status="Running", audienceIds=[],
variations=[], forcedVariations={}, trafficAllocation=[],
layerId="1", type="a/b"
)
self.assertEqual("a/b", exp_with_type.type)

# Test with type not set (default is None)
exp_without_type = entities.Experiment(
id="456", key="test2", status="Running", audienceIds=[],
variations=[], forcedVariations={}, trafficAllocation=[],
layerId="1"
)
self.assertIsNone(exp_without_type.type)

def test_supported_experiment_types_values(self):
"""Test that the supported experiment types contain the expected values."""
expected_types = {'a/b', 'mab', 'cmab', 'feature_rollouts'}
self.assertEqual(expected_types, entities.ExperimentTypes.SUPPORTED_TYPES)
Loading