1- # Copyright 2017-2022, Optimizely
1+ # Copyright 2017-2022, 2026, Optimizely
22# Licensed under the Apache License, Version 2.0 (the "License");
33# you may not use this file except in compliance with the License.
44# You may obtain a copy of the License at
@@ -65,7 +65,8 @@ class VariationResult(TypedDict):
6565
6666
6767class _DecisionResultOptional (TypedDict , total = False ):
68- holdout_decision : Decision
68+ holdout_decision : Decision # Deprecated: first entry of holdout_decisions.
69+ holdout_decisions : List [Decision ]
6970
7071
7172class DecisionResult (_DecisionResultOptional ):
@@ -552,7 +553,11 @@ def get_variation(
552553 }
553554
554555 def get_variation_for_rollout (
555- self , project_config : ProjectConfig , feature : entities .FeatureFlag , user_context : OptimizelyUserContext
556+ self ,
557+ project_config : ProjectConfig ,
558+ feature : entities .FeatureFlag ,
559+ user_context : OptimizelyUserContext ,
560+ holdout_decisions : Optional [list [Decision ]] = None
556561 ) -> tuple [Decision , list [str ]]:
557562 """ Determine which experiment/variation the user is in for a given rollout.
558563 Returns the variation of the first experiment the user qualifies for.
@@ -562,7 +567,7 @@ def get_variation_for_rollout(
562567 flagKey: Feature key.
563568 rollout: Rollout for which we are getting the variation.
564569 user: ID and attributes for user.
565- options: Decide options .
570+ holdout_decisions: If provided, local holdout decisions for skipped rules are appended to it .
566571
567572 Returns:
568573 Decision namedtuple consisting of experiment and variation for the user and
@@ -606,22 +611,14 @@ def get_variation_for_rollout(
606611 return Decision (experiment = rule , variation = forced_decision_variation ,
607612 source = enums .DecisionSources .ROLLOUT , cmab_uuid = None ), decide_reasons
608613
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
614+ local_holdout = self ._get_local_holdout_decision (
615+ project_config , rule , user_context , 'delivery' , decide_reasons
616+ )
617+ if local_holdout is not None :
618+ if holdout_decisions is not None :
619+ holdout_decisions .append (local_holdout )
620+ index += 1
621+ continue
625622
626623 bucketing_id , bucket_reasons = self ._get_bucketing_id (user_id , attributes )
627624 decide_reasons += bucket_reasons
@@ -748,6 +745,7 @@ def get_decision_for_flag(
748745
749746 global_holdout_result : DecisionResult | None = None
750747 global_holdout_key : str | None = None
748+ holdout_decisions : list [Decision ] = []
751749
752750 # Check global holdouts (flag level — before any rules are evaluated)
753751 global_holdouts = project_config .get_global_holdouts ()
@@ -781,6 +779,7 @@ def get_decision_for_flag(
781779 reasons .append (message )
782780 global_holdout_result = holdout_decision
783781 global_holdout_key = holdout .key
782+ holdout_decisions .append (holdout_decision ['decision' ])
784783 break
785784
786785 # Check experiments then rollouts
@@ -807,30 +806,17 @@ def get_decision_for_flag(
807806 'error' : False ,
808807 'reasons' : reasons
809808 }
810- if global_holdout_result is not None :
811- result ['holdout_decision' ] = global_holdout_result ['decision' ]
809+ if holdout_decisions :
810+ result ['holdout_decisions' ] = holdout_decisions
811+ result ['holdout_decision' ] = holdout_decisions [0 ]
812812 return result
813813
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- }
814+ local_holdout = self ._get_local_holdout_decision (
815+ project_config , experiment , user_context , 'experiment' , reasons
816+ )
817+ if local_holdout is not None :
818+ holdout_decisions .append (local_holdout )
819+ continue
834820
835821 # Get variation for experiment
836822 variation_result = self .get_variation (
@@ -856,8 +842,9 @@ def get_decision_for_flag(
856842 'error' : False ,
857843 'reasons' : reasons
858844 }
859- if global_holdout_result is not None :
860- result ['holdout_decision' ] = global_holdout_result ['decision' ]
845+ if holdout_decisions :
846+ result ['holdout_decisions' ] = holdout_decisions
847+ result ['holdout_decision' ] = holdout_decisions [0 ]
861848 return result
862849
863850 if global_holdout_result is not None :
@@ -870,7 +857,7 @@ def get_decision_for_flag(
870857
871858 # If no experiment decision, check rollouts
872859 rollout_decision , rollout_reasons = self .get_variation_for_rollout (
873- project_config , feature_flag , user_context
860+ project_config , feature_flag , user_context , holdout_decisions
874861 )
875862 if rollout_reasons :
876863 reasons .extend (rollout_reasons )
@@ -893,10 +880,33 @@ def get_decision_for_flag(
893880 'error' : False ,
894881 'reasons' : reasons
895882 }
896- if global_holdout_result is not None :
897- final_result ['holdout_decision' ] = global_holdout_result ['decision' ]
883+ if holdout_decisions :
884+ final_result ['holdout_decisions' ] = holdout_decisions
885+ final_result ['holdout_decision' ] = holdout_decisions [0 ]
898886 return final_result
899887
888+ def _get_local_holdout_decision (
889+ self ,
890+ project_config : ProjectConfig ,
891+ rule : entities .Experiment ,
892+ user_context : OptimizelyUserContext ,
893+ rule_type : str ,
894+ reasons : list [str ]
895+ ) -> Optional [Decision ]:
896+ """Returns the first local holdout decision the user is bucketed into for the rule, if any."""
897+ for holdout in project_config .get_holdouts_for_rule (rule .id ):
898+ holdout_result = self .get_variation_for_holdout (holdout , user_context , project_config )
899+ reasons .extend (holdout_result ['reasons' ])
900+ if holdout_result ['decision' ].variation is not None :
901+ message = (
902+ f"The user '{ user_context .user_id } ' is bucketed into local holdout '{ holdout .key } ' "
903+ f"for { rule_type } rule '{ rule .key } '. Skipping the rule."
904+ )
905+ self .logger .info (message )
906+ reasons .append (message )
907+ return holdout_result ['decision' ]
908+ return None
909+
900910 def get_variation_for_holdout (
901911 self ,
902912 holdout : entities .Holdout ,
0 commit comments