@@ -670,7 +670,160 @@ def get_variation_for_feature(
670670 - 'error': Boolean indicating if an error occurred during the decision process.
671671 - 'reasons': List of log messages representing decision making for the feature.
672672 """
673- return self .get_variations_for_feature_list (project_config , [feature ], user_context , options )[0 ]
673+ holdouts = project_config .get_holdouts_for_flag (feature .key )
674+
675+ if holdouts :
676+ # Has holdouts - use get_decision_for_flag which checks holdouts first
677+ return self .get_decision_for_flag (feature , user_context , project_config , options )
678+ else :
679+ return self .get_variations_for_feature_list (project_config , [feature ], user_context , options )[0 ]
680+
681+ def get_decision_for_flag (
682+ self ,
683+ feature_flag : entities .FeatureFlag ,
684+ user_context : OptimizelyUserContext ,
685+ project_config : ProjectConfig ,
686+ decide_options : Optional [Sequence [str ]] = None ,
687+ user_profile_tracker : Optional [UserProfileTracker ] = None ,
688+ decide_reasons : Optional [list [str ]] = None
689+ ) -> DecisionResult :
690+ """
691+ Get the decision for a single feature flag.
692+ Processes holdouts, experiments, and rollouts in that order.
693+
694+ Args:
695+ feature_flag: The feature flag to get a decision for.
696+ user_context: The user context.
697+ project_config: The project config.
698+ decide_options: Sequence of decide options.
699+ user_profile_tracker: The user profile tracker.
700+ decide_reasons: List of decision reasons to merge.
701+
702+ Returns:
703+ A DecisionResult for the feature flag.
704+ """
705+ reasons = decide_reasons .copy () if decide_reasons else []
706+ user_id = user_context .user_id
707+
708+ # Check holdouts
709+ holdouts = project_config .get_holdouts_for_flag (feature_flag .key )
710+ for holdout in holdouts :
711+ holdout_decision = self .get_variation_for_holdout (holdout , user_context , project_config )
712+ reasons .extend (holdout_decision ['reasons' ])
713+
714+ if not holdout_decision ['decision' ]:
715+ continue
716+
717+ message = f"The user '{ user_id } ' is bucketed into holdout '{ holdout ['key' ]} ' " \
718+ f"for feature flag '{ feature_flag .key } '."
719+ self .logger .info (message )
720+ reasons .append (message )
721+ return {
722+ 'decision' : holdout_decision ['decision' ],
723+ 'error' : False ,
724+ 'reasons' : reasons
725+ }
726+
727+ # If no holdout decision, fall back to existing experiment/rollout logic
728+ # Use get_variations_for_feature_list which handles experiments and rollouts
729+ fallback_result = self .get_variations_for_feature_list (
730+ project_config , [feature_flag ], user_context , decide_options
731+ )[0 ]
732+
733+ # Merge reasons
734+ if fallback_result .get ('reasons' ):
735+ reasons .extend (fallback_result ['reasons' ])
736+
737+ return {
738+ 'decision' : fallback_result .get ('decision' ),
739+ 'error' : fallback_result .get ('error' , False ),
740+ 'reasons' : reasons
741+ }
742+
743+ def get_variation_for_holdout (
744+ self ,
745+ holdout : dict [str , str ],
746+ user_context : OptimizelyUserContext ,
747+ project_config : ProjectConfig
748+ ) -> DecisionResult :
749+ """
750+ Get the variation for holdout.
751+
752+ Args:
753+ holdout: The holdout configuration.
754+ user_context: The user context.
755+ project_config: The project config.
756+
757+ Returns:
758+ A DecisionResult for the holdout.
759+ """
760+ from optimizely .helpers .enums import ExperimentAudienceEvaluationLogs
761+
762+ decide_reasons : list [str ] = []
763+ user_id = user_context .user_id
764+ attributes = user_context .get_user_attributes ()
765+
766+ if not holdout or not holdout .get ('status' ) or holdout .get ('status' ) != 'Running' :
767+ key = holdout .get ('key' ) if holdout else 'unknown'
768+ message = f"Holdout '{ key } ' is not running."
769+ self .logger .info (message )
770+ decide_reasons .append (message )
771+ return {
772+ 'decision' : None ,
773+ 'error' : False ,
774+ 'reasons' : decide_reasons
775+ }
776+
777+ bucketing_id , bucketing_id_reasons = self ._get_bucketing_id (user_id , attributes )
778+ decide_reasons .extend (bucketing_id_reasons )
779+
780+ # Check audience conditions
781+ audience_conditions = holdout .get ('audienceIds' )
782+ user_meets_audience_conditions , reasons_received = audience_helper .does_user_meet_audience_conditions (
783+ project_config ,
784+ audience_conditions ,
785+ ExperimentAudienceEvaluationLogs ,
786+ holdout .get ('key' , 'unknown' ),
787+ user_context ,
788+ self .logger
789+ )
790+ decide_reasons .extend (reasons_received )
791+
792+ if not user_meets_audience_conditions :
793+ message = f"User '{ user_id } ' does not meet the conditions for holdout '{ holdout ['key' ]} '."
794+ self .logger .debug (message )
795+ decide_reasons .append (message )
796+ return {
797+ 'decision' : None ,
798+ 'error' : False ,
799+ 'reasons' : decide_reasons
800+ }
801+
802+ # Bucket user into holdout variation
803+ variation , bucket_reasons = self .bucketer .bucket (project_config , holdout , user_id , bucketing_id )
804+ decide_reasons .extend (bucket_reasons )
805+
806+ if variation :
807+ message = f"The user '{ user_id } ' is bucketed into variation '{ variation ['key' ]} ' " \
808+ f"of holdout '{ holdout ['key' ]} '."
809+ self .logger .info (message )
810+ decide_reasons .append (message )
811+
812+ holdout_decision = Decision (holdout , variation , enums .DecisionSources .HOLDOUT , None )
813+ return {
814+ 'decision' : holdout_decision ,
815+ 'error' : False ,
816+ 'reasons' : decide_reasons
817+ }
818+
819+ message = f"User '{ user_id } ' is not bucketed into any variation for holdout '{ holdout ['key' ]} '."
820+ self .logger .info (message )
821+ decide_reasons .append (message )
822+ return {
823+ 'decision' : None ,
824+ 'error' : False ,
825+ 'reasons' : decide_reasons
826+ }
674827
675828 def validated_forced_decision (
676829 self ,
0 commit comments