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,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,11 @@ def get_variation(
552552 }
553553
554554 def get_variation_for_rollout (
555- self , project_config : ProjectConfig , feature : entities .FeatureFlag , user_context : OptimizelyUserContext
555+ self ,
556+ project_config : ProjectConfig ,
557+ feature : entities .FeatureFlag ,
558+ user_context : OptimizelyUserContext ,
559+ holdout_decisions : Optional [list [Decision ]] = None
556560 ) -> tuple [Decision , list [str ]]:
557561 """ Determine which experiment/variation the user is in for a given rollout.
558562 Returns the variation of the first experiment the user qualifies for.
@@ -562,7 +566,7 @@ def get_variation_for_rollout(
562566 flagKey: Feature key.
563567 rollout: Rollout for which we are getting the variation.
564568 user: ID and attributes for user.
565- options: Decide options .
569+ holdout_decisions: If provided, local holdout decisions for skipped rules are appended to it .
566570
567571 Returns:
568572 Decision namedtuple consisting of experiment and variation for the user and
@@ -606,22 +610,14 @@ def get_variation_for_rollout(
606610 return Decision (experiment = rule , variation = forced_decision_variation ,
607611 source = enums .DecisionSources .ROLLOUT , cmab_uuid = None ), decide_reasons
608612
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
613+ local_holdout = self ._get_local_holdout_decision (
614+ project_config , rule , user_context , 'delivery' , decide_reasons
615+ )
616+ if local_holdout is not None :
617+ if holdout_decisions is not None :
618+ holdout_decisions .append (local_holdout )
619+ index += 1
620+ continue
625621
626622 bucketing_id , bucket_reasons = self ._get_bucketing_id (user_id , attributes )
627623 decide_reasons += bucket_reasons
@@ -748,6 +744,7 @@ def get_decision_for_flag(
748744
749745 global_holdout_result : DecisionResult | None = None
750746 global_holdout_key : str | None = None
747+ holdout_decisions : list [Decision ] = []
751748
752749 # Check global holdouts (flag level — before any rules are evaluated)
753750 global_holdouts = project_config .get_global_holdouts ()
@@ -781,6 +778,7 @@ def get_decision_for_flag(
781778 reasons .append (message )
782779 global_holdout_result = holdout_decision
783780 global_holdout_key = holdout .key
781+ holdout_decisions .append (holdout_decision ['decision' ])
784782 break
785783
786784 # Check experiments then rollouts
@@ -807,30 +805,16 @@ def get_decision_for_flag(
807805 'error' : False ,
808806 'reasons' : reasons
809807 }
810- if global_holdout_result is not None :
811- result ['holdout_decision ' ] = global_holdout_result [ 'decision' ]
808+ if holdout_decisions :
809+ result ['holdout_decisions ' ] = holdout_decisions
812810 return result
813811
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- }
812+ local_holdout = self ._get_local_holdout_decision (
813+ project_config , experiment , user_context , 'experiment' , reasons
814+ )
815+ if local_holdout is not None :
816+ holdout_decisions .append (local_holdout )
817+ continue
834818
835819 # Get variation for experiment
836820 variation_result = self .get_variation (
@@ -856,8 +840,8 @@ def get_decision_for_flag(
856840 'error' : False ,
857841 'reasons' : reasons
858842 }
859- if global_holdout_result is not None :
860- result ['holdout_decision ' ] = global_holdout_result [ 'decision' ]
843+ if holdout_decisions :
844+ result ['holdout_decisions ' ] = holdout_decisions
861845 return result
862846
863847 if global_holdout_result is not None :
@@ -870,7 +854,7 @@ def get_decision_for_flag(
870854
871855 # If no experiment decision, check rollouts
872856 rollout_decision , rollout_reasons = self .get_variation_for_rollout (
873- project_config , feature_flag , user_context
857+ project_config , feature_flag , user_context , holdout_decisions
874858 )
875859 if rollout_reasons :
876860 reasons .extend (rollout_reasons )
@@ -893,10 +877,32 @@ def get_decision_for_flag(
893877 'error' : False ,
894878 'reasons' : reasons
895879 }
896- if global_holdout_result is not None :
897- final_result ['holdout_decision ' ] = global_holdout_result [ 'decision' ]
880+ if holdout_decisions :
881+ final_result ['holdout_decisions ' ] = holdout_decisions
898882 return final_result
899883
884+ def _get_local_holdout_decision (
885+ self ,
886+ project_config : ProjectConfig ,
887+ rule : entities .Experiment ,
888+ user_context : OptimizelyUserContext ,
889+ rule_type : str ,
890+ reasons : list [str ]
891+ ) -> Optional [Decision ]:
892+ """Returns the first local holdout decision the user is bucketed into for the rule, if any."""
893+ for holdout in project_config .get_holdouts_for_rule (rule .id ):
894+ holdout_result = self .get_variation_for_holdout (holdout , user_context , project_config )
895+ reasons .extend (holdout_result ['reasons' ])
896+ if holdout_result ['decision' ].variation is not None :
897+ message = (
898+ f"The user '{ user_context .user_id } ' is bucketed into local holdout '{ holdout .key } ' "
899+ f"for { rule_type } rule '{ rule .key } '. Skipping the rule."
900+ )
901+ self .logger .info (message )
902+ reasons .append (message )
903+ return holdout_result ['decision' ]
904+ return None
905+
900906 def get_variation_for_holdout (
901907 self ,
902908 holdout : entities .Holdout ,
0 commit comments