From 39336eedfcfd516625cd12234472d7763c203519 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Mon, 2 Feb 2026 11:50:37 -0800 Subject: [PATCH 1/3] Revert "[FSSDK-12149] [Python] Add Event Retries (#475)" This reverts commit f98886af41e22d3d565c880b8e98efb054ae641a. --- optimizely/event_dispatcher.py | 2 +- optimizely/odp/odp_event_manager.py | 9 +-------- tests/test_odp_event_manager.py | 12 ++++-------- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/optimizely/event_dispatcher.py b/optimizely/event_dispatcher.py index b06b9e1..55209dc 100644 --- a/optimizely/event_dispatcher.py +++ b/optimizely/event_dispatcher.py @@ -49,7 +49,7 @@ def dispatch_event(event: event_builder.Event) -> None: session = requests.Session() retries = Retry(total=EventDispatchConfig.RETRIES, - backoff_factor=0.2, + backoff_factor=0.1, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retries) diff --git a/optimizely/odp/odp_event_manager.py b/optimizely/odp/odp_event_manager.py index 3fb961a..85512e9 100644 --- a/optimizely/odp/odp_event_manager.py +++ b/optimizely/odp/odp_event_manager.py @@ -163,8 +163,6 @@ def _flush_batch(self) -> None: self.logger.debug(f'ODP event queue: flushing batch size {batch_len}.') should_retry = False - initial_retry_interval = 0.2 # 200ms - max_retry_interval = 1.0 # 1 second for i in range(1 + self.retry_count): try: @@ -178,12 +176,7 @@ def _flush_batch(self) -> None: if not should_retry: break if i < self.retry_count: - # Exponential backoff: 200ms, 400ms, 800ms, ... capped at 1s - delay = initial_retry_interval * (2 ** i) - if delay > max_retry_interval: - delay = max_retry_interval - self.logger.debug(f'Error dispatching ODP events, retrying after {delay}s.') - time.sleep(delay) + self.logger.debug('Error dispatching ODP events, scheduled to retry.') if should_retry: self.logger.error(Errors.ODP_EVENT_FAILED.format(f'Failed after {i} retries: {self._current_batch}')) diff --git a/tests/test_odp_event_manager.py b/tests/test_odp_event_manager.py index acec396..d9d29ea 100644 --- a/tests/test_odp_event_manager.py +++ b/tests/test_odp_event_manager.py @@ -265,7 +265,7 @@ def test_odp_event_manager_retry_failure(self, *args): with mock.patch.object( event_manager.api_manager, 'send_odp_events', new_callable=CopyingMock, return_value=True - ) as mock_send, mock.patch('time.sleep') as mock_sleep: + ) as mock_send: event_manager.send_event(**self.events[0]) event_manager.send_event(**self.events[1]) event_manager.flush() @@ -275,9 +275,7 @@ def test_odp_event_manager_retry_failure(self, *args): [mock.call(self.api_key, self.api_host, self.processed_events)] * number_of_tries ) self.assertEqual(len(event_manager._current_batch), 0) - # Verify exponential backoff delays: 0.2s, 0.4s, 0.8s - mock_sleep.assert_has_calls([mock.call(0.2), mock.call(0.4), mock.call(0.8)]) - mock_logger.debug.assert_any_call('Error dispatching ODP events, retrying after 0.2s.') + mock_logger.debug.assert_any_call('Error dispatching ODP events, scheduled to retry.') mock_logger.error.assert_called_once_with( f'ODP event send failed (Failed after 3 retries: {self.processed_events}).' ) @@ -290,7 +288,7 @@ def test_odp_event_manager_retry_success(self, *args): with mock.patch.object( event_manager.api_manager, 'send_odp_events', new_callable=CopyingMock, side_effect=[True, True, False] - ) as mock_send, mock.patch('time.sleep') as mock_sleep: + ) as mock_send: event_manager.send_event(**self.events[0]) event_manager.send_event(**self.events[1]) event_manager.flush() @@ -298,9 +296,7 @@ def test_odp_event_manager_retry_success(self, *args): mock_send.assert_has_calls([mock.call(self.api_key, self.api_host, self.processed_events)] * 3) self.assertEqual(len(event_manager._current_batch), 0) - # Verify exponential backoff delays: 0.2s, 0.4s (only 2 delays for 3 attempts) - mock_sleep.assert_has_calls([mock.call(0.2), mock.call(0.4)]) - mock_logger.debug.assert_any_call('Error dispatching ODP events, retrying after 0.2s.') + mock_logger.debug.assert_any_call('Error dispatching ODP events, scheduled to retry.') mock_logger.error.assert_not_called() self.assertStrictTrue(event_manager.is_running) event_manager.stop() From 88b06441910f33c1b253131f821dd8c166a04d5f Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Mon, 2 Feb 2026 11:50:42 -0800 Subject: [PATCH 2/3] Revert "[FSSDK-12035] Update: Exclude CMAB from UserProfileService (#474)" This reverts commit eadf141b00d21131ca0e214d10d351b6156b2c34. --- optimizely/decision_service.py | 5 -- tests/test_decision_service.py | 118 --------------------------------- 2 files changed, 123 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index be2be2c..28275ef 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -515,11 +515,6 @@ def get_variation( 'reasons': decide_reasons, 'variation': None } - ignore_user_profile = True - self.logger.debug( - f'Skipping user profile service for CMAB experiment "{experiment.key}". ' - f'CMAB decisions are dynamic and not stored for sticky bucketing.' - ) variation_id = cmab_decision['variation_id'] if cmab_decision else None cmab_uuid = cmab_decision['cmab_uuid'] if cmab_decision else None variation = project_config.get_variation_from_id(experiment_key=experiment.key, diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index b38a03b..dbcb743 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1074,124 +1074,6 @@ def test_get_variation_cmab_experiment_with_whitelisted_variation(self): mock_bucket.assert_not_called() mock_cmab_decision.assert_not_called() - def test_get_variation_cmab_experiment_does_not_save_user_profile(self): - """Test that CMAB experiments do not save bucketing decisions to user profile.""" - - # Create a user context - user = optimizely_user_context.OptimizelyUserContext( - optimizely_client=None, - logger=None, - user_id="test_user", - user_attributes={} - ) - - # Create a user profile service and tracker - user_profile_service = user_profile.UserProfileService() - user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service) - - # Create a CMAB experiment - cmab_experiment = entities.Experiment( - '111150', - 'cmab_experiment', - 'Running', - '111150', - [], # No audience IDs - {}, - [ - entities.Variation('111151', 'variation_1'), - entities.Variation('111152', 'variation_2') - ], - [ - {'entityId': '111151', 'endOfRange': 5000}, - {'entityId': '111152', 'endOfRange': 10000} - ], - cmab={'trafficAllocation': 5000} - ) - - with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ - mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \ - mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id', - return_value=['$', []]), \ - mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \ - mock.patch.object(self.project_config, 'get_variation_from_id', - return_value=entities.Variation('111151', 'variation_1')), \ - mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile, \ - mock.patch.object(self.decision_service, 'logger') as mock_logger: - - # Configure CMAB service to return a decision - mock_cmab_service.get_decision.return_value = ( - { - 'variation_id': '111151', - 'cmab_uuid': 'test-cmab-uuid-123' - }, - [] # reasons list - ) - - # Call get_variation with the CMAB experiment and user profile tracker - variation_result = self.decision_service.get_variation( - self.project_config, - cmab_experiment, - user, - user_profile_tracker - ) - variation = variation_result['variation'] - cmab_uuid = variation_result['cmab_uuid'] - - # Verify the variation and cmab_uuid are returned - self.assertEqual(entities.Variation('111151', 'variation_1'), variation) - self.assertEqual('test-cmab-uuid-123', cmab_uuid) - - # Verify user profile was NOT updated for CMAB experiment - mock_update_profile.assert_not_called() - - # Verify debug log was called to explain CMAB exclusion - mock_logger.debug.assert_any_call( - 'Skipping user profile service for CMAB experiment "cmab_experiment". ' - 'CMAB decisions are dynamic and not stored for sticky bucketing.' - ) - - def test_get_variation_standard_experiment_saves_user_profile(self): - """Test that standard (non-CMAB) experiments DO save bucketing decisions to user profile.""" - - user = optimizely_user_context.OptimizelyUserContext( - optimizely_client=None, - logger=None, - user_id="test_user", - user_attributes={} - ) - - # Create a user profile service and tracker - user_profile_service = user_profile.UserProfileService() - user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service) - - # Get a standard (non-CMAB) experiment - experiment = self.project_config.get_experiment_from_key("test_experiment") - - with mock.patch('optimizely.decision_service.DecisionService.get_whitelisted_variation', - return_value=[None, []]), \ - mock.patch('optimizely.decision_service.DecisionService.get_stored_variation', - return_value=None), \ - mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', - return_value=[True, []]), \ - mock.patch('optimizely.bucketer.Bucketer.bucket', - return_value=[entities.Variation("111129", "variation"), []]), \ - mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile: - - # Call get_variation with standard experiment and user profile tracker - variation_result = self.decision_service.get_variation( - self.project_config, - experiment, - user, - user_profile_tracker - ) - variation = variation_result['variation'] - - # Verify variation was returned - self.assertEqual(entities.Variation("111129", "variation"), variation) - - # Verify user profile WAS updated for standard experiment - mock_update_profile.assert_called_once_with(experiment, variation) - class FeatureFlagDecisionTests(base.BaseTest): def setUp(self): From 74da37e9ba77d1bfe7dccf5280682fd9185d7aa4 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Mon, 2 Feb 2026 12:06:07 -0800 Subject: [PATCH 3/3] [FSSDK-12262] Exclude CMAB from UserProfileService Co-Authored-By: Claude Sonnet 4.5 --- optimizely/decision_service.py | 3 +- tests/test_decision_service.py | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 28275ef..2e2bd2c 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -529,7 +529,8 @@ def get_variation( self.logger.info(message) decide_reasons.append(message) # Store this new decision and return the variation for the user - if user_profile_tracker is not None and not ignore_user_profile: + # Exclude CMAB experiments from user profile service + if user_profile_tracker is not None and not ignore_user_profile and not experiment.cmab: try: user_profile_tracker.update_user_profile(experiment, variation) except: diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index dbcb743..c576e2a 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1074,6 +1074,77 @@ def test_get_variation_cmab_experiment_with_whitelisted_variation(self): mock_bucket.assert_not_called() mock_cmab_decision.assert_not_called() + def test_get_variation_cmab_experiment_excludes_user_profile_service(self): + """Test that CMAB decisions are not saved to user profile service.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a user profile service and tracker + user_profile_service = user_profile.UserProfileService() + user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service) + + # Create a CMAB experiment + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], # No audience IDs + {}, # No forced variations + [ + entities.Variation('111151', 'variation_1'), + entities.Variation('111152', 'variation_2') + ], + [ + {'entityId': '111151', 'endOfRange': 5000}, + {'entityId': '111152', 'endOfRange': 10000} + ], + cmab={'trafficAllocation': 5000} + ) + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ + mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \ + mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id', + return_value=['$', []]), \ + mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \ + mock.patch.object(self.project_config, 'get_variation_from_id', + return_value=entities.Variation('111151', 'variation_1')), \ + mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile: + + # Configure CMAB service to return a decision + mock_cmab_service.get_decision.return_value = ( + { + 'variation_id': '111151', + 'cmab_uuid': 'test-cmab-uuid-123' + }, + [] # reasons list + ) + + # Call get_variation with the CMAB experiment and user profile tracker + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + user_profile_tracker + ) + variation = variation_result['variation'] + cmab_uuid = variation_result['cmab_uuid'] + error = variation_result['error'] + + # Verify the variation and cmab_uuid were returned correctly + self.assertEqual(entities.Variation('111151', 'variation_1'), variation) + self.assertEqual('test-cmab-uuid-123', cmab_uuid) + self.assertStrictFalse(error) + + # Verify user profile was NOT updated (CMAB should be excluded) + mock_update_profile.assert_not_called() + class FeatureFlagDecisionTests(base.BaseTest): def setUp(self):