Skip to content

Commit 265d82b

Browse files
update: enhance decision result handling by introducing VariationResult and updating get_variation return type to include detailed error information
1 parent 60a4ada commit 265d82b

File tree

3 files changed

+157
-53
lines changed

3 files changed

+157
-53
lines changed

optimizely/decision_service.py

Lines changed: 133 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,49 @@
3434

3535

3636
class CmabDecisionResult(TypedDict):
37+
"""
38+
TypedDict representing the result of a CMAB (Contextual Multi-Armed Bandit) decision.
39+
40+
Attributes:
41+
error (bool): Indicates whether an error occurred during the decision process.
42+
result (Optional[CmabDecision]): Resulting CmabDecision object if the decision was successful, otherwise None.
43+
reasons (List[str]): A list of reasons or messages explaining the outcome or any errors encountered.
44+
"""
3745
error: bool
3846
result: Optional[CmabDecision]
3947
reasons: List[str]
4048

4149

50+
class VariationResult(TypedDict):
51+
"""
52+
TypedDict representing the result of a variation decision process.
53+
54+
Attributes:
55+
cmab_uuid (Optional[str]): The unique identifier for the CMAB experiment, if applicable.
56+
error (bool): Indicates whether an error occurred during the decision process.
57+
reasons (List[str]): A list of reasons explaining the outcome or any errors encountered.
58+
variation (Optional[entities.Variation]): The selected variation entity, or None if no variation was assigned.
59+
"""
60+
cmab_uuid: Optional[str]
61+
error: bool
62+
reasons: List[str]
63+
variation: Optional[entities.Variation]
64+
65+
66+
class DecisionResult(TypedDict):
67+
"""
68+
A TypedDict representing the result of a decision process.
69+
70+
Attributes:
71+
decision (Decision): The decision object containing the outcome of the evaluation.
72+
error (bool): Indicates whether an error occurred during the decision process.
73+
reasons (List[str]): A list of reasons explaining the decision or any errors encountered.
74+
"""
75+
decision: Decision
76+
error: bool
77+
reasons: List[str]
78+
79+
4280
class Decision(NamedTuple):
4381
"""Named tuple containing selected experiment, variation, source and cmab_uuid.
4482
None if no experiment/variation was selected."""
@@ -310,30 +348,38 @@ def get_variation(
310348
user_profile_tracker: Optional[UserProfileTracker],
311349
reasons: list[str] = [],
312350
options: Optional[Sequence[str]] = None
313-
) -> tuple[Optional[entities.Variation], list[str], Optional[str]]:
314-
""" Top-level function to help determine variation user should be put in.
315-
316-
First, check if experiment is running.
317-
Second, check if user is forced in a variation.
318-
Third, check if there is a stored decision for the user and return the corresponding variation.
319-
Fourth, figure out if user is in the experiment by evaluating audience conditions if any.
320-
Fifth, bucket the user and return the variation.
351+
) -> VariationResult:
352+
"""
353+
Determines the variation a user should be assigned to for a given experiment.
354+
355+
The decision process is as follows:
356+
1. Check if the experiment is running.
357+
2. Check if the user is forced into a variation via the forced variation map.
358+
3. Check if the user is whitelisted into a variation for the experiment.
359+
4. If user profile tracking is enabled and not ignored, check for a stored variation.
360+
5. Evaluate audience conditions to determine if the user qualifies for the experiment.
361+
6. For CMAB experiments:
362+
a. Check if the user is in the CMAB traffic allocation.
363+
b. If so, fetch the CMAB decision and assign the corresponding variation and cmab_uuid.
364+
7. For non-CMAB experiments, bucket the user into a variation.
365+
8. If a variation is assigned, optionally update the user profile.
366+
9. Return the assigned variation, decision reasons, and cmab_uuid (if applicable).
321367
322368
Args:
323-
project_config: Instance of ProjectConfig.
324-
experiment: Experiment for which user variation needs to be determined.
325-
user_context: contains user id and attributes.
326-
user_profile_tracker: tracker for reading and updating user profile of the user.
327-
reasons: Decision reasons.
328-
options: Decide options.
369+
project_config: Instance of ProjectConfig.
370+
experiment: Experiment for which the user's variation needs to be determined.
371+
user_context: Contains user id and attributes.
372+
user_profile_tracker: Tracker for reading and updating the user's profile.
373+
reasons: List of decision reasons.
374+
options: Decide options.
329375
330376
Returns:
331-
Variation user should see. None if user is not in experiment or experiment is not running,
332-
an array of log messages representing decision making
333-
and a cmab_uuid if experiment is cmab-experiment
377+
A tuple of:
378+
- The assigned Variation (or None if not assigned).
379+
- A list of log messages representing decision making.
380+
- The cmab_uuid if the experiment is a CMAB experiment, otherwise None.
334381
"""
335382
user_id = user_context.user_id
336-
cmab_uuid = None
337383
if options:
338384
ignore_user_profile = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in options
339385
else:
@@ -347,20 +393,35 @@ def get_variation(
347393
message = f'Experiment "{experiment.key}" is not running.'
348394
self.logger.info(message)
349395
decide_reasons.append(message)
350-
return None, decide_reasons, cmab_uuid
396+
return {
397+
'cmab_uuid': None,
398+
'error': False,
399+
'reasons': decide_reasons,
400+
'variation': None
401+
}
351402

352403
# Check if the user is forced into a variation
353404
variation: Optional[entities.Variation]
354405
variation, reasons_received = self.get_forced_variation(project_config, experiment.key, user_id)
355406
decide_reasons += reasons_received
356407
if variation:
357-
return variation, decide_reasons, cmab_uuid
408+
return {
409+
'cmab_uuid': None,
410+
'error': False,
411+
'reasons': decide_reasons,
412+
'variation': variation
413+
}
358414

359415
# Check to see if user is white-listed for a certain variation
360416
variation, reasons_received = self.get_whitelisted_variation(project_config, experiment, user_id)
361417
decide_reasons += reasons_received
362418
if variation:
363-
return variation, decide_reasons, cmab_uuid
419+
return {
420+
'cmab_uuid': None,
421+
'error': False,
422+
'reasons': decide_reasons,
423+
'variation': variation
424+
}
364425

365426
# Check to see if user has a decision available for the given experiment
366427
if user_profile_tracker is not None and not ignore_user_profile:
@@ -370,7 +431,12 @@ def get_variation(
370431
f'"{experiment}" for user "{user_id}" from user profile.'
371432
self.logger.info(message)
372433
decide_reasons.append(message)
373-
return variation, decide_reasons, cmab_uuid
434+
return {
435+
'cmab_uuid': None,
436+
'error': False,
437+
'reasons': decide_reasons,
438+
'variation': variation
439+
}
374440
else:
375441
self.logger.warning('User profile has invalid format.')
376442

@@ -386,12 +452,21 @@ def get_variation(
386452
message = f'User "{user_id}" does not meet conditions to be in experiment "{experiment.key}".'
387453
self.logger.info(message)
388454
decide_reasons.append(message)
389-
return None, decide_reasons, cmab_uuid
455+
return {
456+
'cmab_uuid': None,
457+
'error': False,
458+
'reasons': decide_reasons,
459+
'variation': None
460+
}
390461

391462
# Determine bucketing ID to be used
392463
bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, user_context.get_user_attributes())
393464
decide_reasons += bucketing_id_reasons
465+
cmab_uuid = None
394466

467+
# Check if this is a CMAB experiment
468+
# If so, handle CMAB-specific traffic allocation and decision logic.
469+
# Otherwise, proceed with standard bucketing logic for non-CMAB experiments.
395470
if experiment.cmab:
396471
CMAB_DUMMY_ENTITY_ID = "$"
397472
# Build the CMAB-specific traffic allocation
@@ -412,18 +487,27 @@ def get_variation(
412487
message = f'User "{user_id}" not in CMAB experiment "{experiment.key}" due to traffic allocation.'
413488
self.logger.info(message)
414489
decide_reasons.append(message)
415-
return None, decide_reasons, cmab_uuid
490+
return {
491+
'cmab_uuid': None,
492+
'error': False,
493+
'reasons': decide_reasons,
494+
'variation': None
495+
}
416496

417497
# User is in CMAB allocation, proceed to CMAB decision
418-
decision_variation_value = self._get_decision_for_cmab_experiment(project_config,
419-
experiment,
420-
user_context,
421-
options)
422-
decide_reasons += decision_variation_value.get('reasons', [])
423-
cmab_decision = decision_variation_value.get('result')
424-
if not cmab_decision or decision_variation_value['error']:
425-
self.logger.error(Errors.CMAB_FETCH_FAILED.format(decide_reasons[0]))
426-
return None, decide_reasons, cmab_uuid
498+
cmab_decision_result = self._get_decision_for_cmab_experiment(project_config,
499+
experiment,
500+
user_context,
501+
options)
502+
decide_reasons += cmab_decision_result.get('reasons', [])
503+
cmab_decision = cmab_decision_result.get('result')
504+
if not cmab_decision or cmab_decision_result['error']:
505+
return {
506+
'cmab_uuid': None,
507+
'error': True,
508+
'reasons': decide_reasons,
509+
'variation': None
510+
}
427511
variation_id = cmab_decision['variation_id']
428512
cmab_uuid = cmab_decision['cmab_uuid']
429513
variation = project_config.get_variation_from_id(experiment_key=experiment.key, variation_id=variation_id)
@@ -442,11 +526,21 @@ def get_variation(
442526
user_profile_tracker.update_user_profile(experiment, variation)
443527
except:
444528
self.logger.exception(f'Unable to save user profile for user "{user_id}".')
445-
return variation, decide_reasons, cmab_uuid
529+
return {
530+
'cmab_uuid': cmab_uuid,
531+
'error': False,
532+
'reasons': decide_reasons,
533+
'variation': variation
534+
}
446535
message = f'User "{user_id}" is in no variation.'
447536
self.logger.info(message)
448537
decide_reasons.append(message)
449-
return None, decide_reasons, cmab_uuid
538+
return {
539+
'cmab_uuid': None,
540+
'error': False,
541+
'reasons': decide_reasons,
542+
'variation': None
543+
}
450544

451545
def get_variation_for_rollout(
452546
self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext
@@ -693,9 +787,12 @@ def get_variations_for_feature_list(
693787
decision_variation = forced_decision_variation
694788
cmab_uuid = None
695789
else:
696-
decision_variation, variation_reasons, cmab_uuid = self.get_variation(
790+
variation_result = self.get_variation(
697791
project_config, experiment, user_context, user_profile_tracker, feature_reasons, options
698792
)
793+
cmab_uuid = variation_result['cmab_uuid']
794+
variation_reasons = variation_result['reasons']
795+
decision_variation = variation_result['variation']
699796
feature_reasons.extend(variation_reasons)
700797

701798
if decision_variation:

optimizely/optimizely.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -652,8 +652,9 @@ def get_variation(
652652
user_context = OptimizelyUserContext(self, self.logger, user_id, attributes, False)
653653
user_profile_tracker = user_profile.UserProfileTracker(user_id, self.user_profile_service, self.logger)
654654
user_profile_tracker.load_user_profile()
655-
variation, _, _ = self.decision_service.get_variation(project_config, experiment,
656-
user_context, user_profile_tracker)
655+
variation_result = self.decision_service.get_variation(project_config, experiment,
656+
user_context, user_profile_tracker)
657+
variation = variation_result['variation']
657658
user_profile_tracker.save_user_profile()
658659
if variation:
659660
variation_key = variation.key

tests/test_decision_service.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -942,31 +942,37 @@ def test_get_variation_cmab_experiment_deep_mock_500_error(self):
942942

943943
# Set up mocks for the entire call chain
944944
with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \
945-
mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \
946-
mock.patch('optimizely.bucketer.Bucketer.bucket_to_entity_id', return_value=['$', []]), \
947-
mock.patch.object(self.decision_service.cmab_service, 'get_decision',
948-
side_effect=lambda *args, **kwargs: self.decision_service.cmab_service._fetch_decision(*args, **kwargs)), \
949-
mock.patch.object(self.decision_service.cmab_service, '_fetch_decision',
950-
side_effect=lambda *args, **kwargs: self.decision_service.cmab_service.cmab_client.fetch_decision(*args, **kwargs)), \
951-
mock.patch.object(self.decision_service.cmab_service.cmab_client, 'fetch_decision',
952-
side_effect=lambda *args, **kwargs: self.decision_service.cmab_service.cmab_client._do_fetch(*args, **kwargs)), \
953-
mock.patch.object(self.decision_service.cmab_service.cmab_client, '_do_fetch',
954-
side_effect=CmabFetchError(error_message)), \
955-
mock.patch.object(self.decision_service, 'logger') as mock_logger:
956-
945+
mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \
946+
mock.patch('optimizely.bucketer.Bucketer.bucket_to_entity_id', return_value=['$', []]), \
947+
mock.patch.object(self.decision_service.cmab_service, 'get_decision',
948+
side_effect=lambda *args,
949+
**kwargs: self.decision_service.cmab_service._fetch_decision(*args, **kwargs)), \
950+
mock.patch.object(self.decision_service.cmab_service, '_fetch_decision',
951+
side_effect=lambda *args,
952+
**kwargs: self.
953+
decision_service.cmab_service.cmab_client.fetch_decision(*args, **kwargs)), \
954+
mock.patch.object(self.decision_service.cmab_service.cmab_client, 'fetch_decision',
955+
side_effect=lambda *args,
956+
**kwargs: self.decision_service.cmab_service.cmab_client._do_fetch(*args, **kwargs)), \
957+
mock.patch.object(self.decision_service.cmab_service.cmab_client, '_do_fetch',
958+
side_effect=CmabFetchError(error_message)), \
959+
mock.patch.object(self.decision_service, 'logger') as mock_logger:
957960
# Call get_variation with the CMAB experiment
958-
variation, reasons, cmab_uuid = self.decision_service.get_variation(
961+
variation_result = self.decision_service.get_variation(
959962
self.project_config,
960963
cmab_experiment,
961964
user,
962965
None
963966
)
964-
967+
variation = variation_result['variation']
968+
cmab_uuid = variation_result['cmab_uuid']
969+
reasons = variation_result['reasons']
970+
965971
# Verify we get no variation due to CMAB service error
966972
self.assertIsNone(variation)
967973
self.assertIsNone(cmab_uuid)
968974
self.assertIn(detailed_error_message, reasons)
969-
975+
970976
# Verify logger was called with the specific 500 error
971977
mock_logger.error.assert_any_call(detailed_error_message)
972978

0 commit comments

Comments
 (0)