@@ -65,7 +65,7 @@ class VariationResult(TypedDict):
6565
6666
6767class _DecisionResultOptional (TypedDict , total = False ):
68- holdout_decision : Decision
68+ holdout_decisions : List [ Decision ]
6969
7070
7171class DecisionResult (_DecisionResultOptional ):
@@ -552,7 +552,8 @@ def get_variation(
552552 }
553553
554554 def get_variation_for_rollout (
555- self , project_config : ProjectConfig , feature : entities .FeatureFlag , user_context : OptimizelyUserContext
555+ self , project_config : ProjectConfig , feature : entities .FeatureFlag , user_context : OptimizelyUserContext ,
556+ holdout_decisions : Optional [list [Decision ]] = None
556557 ) -> tuple [Decision , list [str ]]:
557558 """ Determine which experiment/variation the user is in for a given rollout.
558559 Returns the variation of the first experiment the user qualifies for.
@@ -563,6 +564,7 @@ def get_variation_for_rollout(
563564 rollout: Rollout for which we are getting the variation.
564565 user: ID and attributes for user.
565566 options: Decide options.
567+ holdout_decisions: If given, local holdout hits are appended to it.
566568
567569 Returns:
568570 Decision namedtuple consisting of experiment and variation for the user and
@@ -606,22 +608,14 @@ def get_variation_for_rollout(
606608 return Decision (experiment = rule , variation = forced_decision_variation ,
607609 source = enums .DecisionSources .ROLLOUT , cmab_uuid = None ), decide_reasons
608610
609- local_holdouts = project_config .get_holdouts_for_rule (rule .id )
610- for holdout in local_holdouts :
611- local_holdout_decision = self .get_variation_for_holdout (
612- holdout , user_context , project_config
613- )
614- decide_reasons .extend (local_holdout_decision ['reasons' ])
615-
616- local_decision = local_holdout_decision ['decision' ]
617- if local_decision .variation is not None :
618- message = (
619- f"The user '{ user_id } ' is bucketed into local holdout '{ holdout .key } ' "
620- f"for delivery rule '{ rule .key } '."
621- )
622- self .logger .info (message )
623- decide_reasons .append (message )
624- return local_decision , decide_reasons
611+ local_holdout_hit = self ._find_local_holdout_hit (
612+ project_config , rule , 'delivery' , user_context , decide_reasons
613+ )
614+ if local_holdout_hit is not None :
615+ if holdout_decisions is not None :
616+ holdout_decisions .append (local_holdout_hit )
617+ index += 1
618+ continue
625619
626620 bucketing_id , bucket_reasons = self ._get_bucketing_id (user_id , attributes )
627621 decide_reasons += bucket_reasons
@@ -748,6 +742,7 @@ def get_decision_for_flag(
748742
749743 global_holdout_result : DecisionResult | None = None
750744 global_holdout_key : str | None = None
745+ holdout_decisions : list [Decision ] = []
751746
752747 # Check global holdouts (flag level — before any rules are evaluated)
753748 global_holdouts = project_config .get_global_holdouts ()
@@ -781,6 +776,7 @@ def get_decision_for_flag(
781776 reasons .append (message )
782777 global_holdout_result = holdout_decision
783778 global_holdout_key = holdout .key
779+ holdout_decisions .append (holdout_decision ['decision' ])
784780 break
785781
786782 # Check experiments then rollouts
@@ -807,30 +803,16 @@ def get_decision_for_flag(
807803 'error' : False ,
808804 'reasons' : reasons
809805 }
810- if global_holdout_result is not None :
811- result ['holdout_decision ' ] = global_holdout_result [ 'decision' ]
806+ if holdout_decisions :
807+ result ['holdout_decisions ' ] = holdout_decisions
812808 return result
813809
814- local_holdouts = project_config .get_holdouts_for_rule (experiment .id )
815- for holdout in local_holdouts :
816- local_holdout_decision = self .get_variation_for_holdout (
817- holdout , user_context , project_config
818- )
819- reasons .extend (local_holdout_decision ['reasons' ])
820-
821- local_decision = local_holdout_decision ['decision' ]
822- if local_decision .variation is not None :
823- message = (
824- f"The user '{ user_id } ' is bucketed into local holdout '{ holdout .key } ' "
825- f"for experiment rule '{ experiment .key } '."
826- )
827- self .logger .info (message )
828- reasons .append (message )
829- return {
830- 'decision' : local_holdout_decision ['decision' ],
831- 'error' : False ,
832- 'reasons' : reasons
833- }
810+ local_holdout_hit = self ._find_local_holdout_hit (
811+ project_config , experiment , 'experiment' , user_context , reasons
812+ )
813+ if local_holdout_hit is not None :
814+ holdout_decisions .append (local_holdout_hit )
815+ continue
834816
835817 # Get variation for experiment
836818 variation_result = self .get_variation (
@@ -856,8 +838,8 @@ def get_decision_for_flag(
856838 'error' : False ,
857839 'reasons' : reasons
858840 }
859- if global_holdout_result is not None :
860- result ['holdout_decision ' ] = global_holdout_result [ 'decision' ]
841+ if holdout_decisions :
842+ result ['holdout_decisions ' ] = holdout_decisions
861843 return result
862844
863845 if global_holdout_result is not None :
@@ -870,7 +852,7 @@ def get_decision_for_flag(
870852
871853 # If no experiment decision, check rollouts
872854 rollout_decision , rollout_reasons = self .get_variation_for_rollout (
873- project_config , feature_flag , user_context
855+ project_config , feature_flag , user_context , holdout_decisions
874856 )
875857 if rollout_reasons :
876858 reasons .extend (rollout_reasons )
@@ -893,10 +875,32 @@ def get_decision_for_flag(
893875 'error' : False ,
894876 'reasons' : reasons
895877 }
896- if global_holdout_result is not None :
897- final_result ['holdout_decision ' ] = global_holdout_result [ 'decision' ]
878+ if holdout_decisions :
879+ final_result ['holdout_decisions ' ] = holdout_decisions
898880 return final_result
899881
882+ def _find_local_holdout_hit (
883+ self ,
884+ project_config : ProjectConfig ,
885+ rule : entities .Experiment ,
886+ rule_type : str ,
887+ user_context : OptimizelyUserContext ,
888+ reasons : list [str ]
889+ ) -> Optional [Decision ]:
890+ """Returns the decision of the first local holdout on the rule that buckets the user, else None."""
891+ for holdout in project_config .get_holdouts_for_rule (rule .id ):
892+ holdout_result = self .get_variation_for_holdout (holdout , user_context , project_config )
893+ reasons .extend (holdout_result ['reasons' ])
894+ if holdout_result ['decision' ].variation is not None :
895+ message = (
896+ f"The user '{ user_context .user_id } ' is bucketed into local holdout '{ holdout .key } ' "
897+ f"for { rule_type } rule '{ rule .key } '. Skipping to the next rule."
898+ )
899+ self .logger .info (message )
900+ reasons .append (message )
901+ return holdout_result ['decision' ]
902+ return None
903+
900904 def get_variation_for_holdout (
901905 self ,
902906 holdout : entities .Holdout ,
0 commit comments