Skip to content

Commit c637878

Browse files
update: enhance decision service to handle error states and improve bucketing logic
1 parent a129854 commit c637878

File tree

6 files changed

+160
-192
lines changed

6 files changed

+160
-192
lines changed

optimizely/bucketer.py

Lines changed: 30 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
if TYPE_CHECKING:
2929
# prevent circular dependenacy by skipping import at runtime
3030
from .project_config import ProjectConfig
31-
from .entities import Experiment, Variation, Group
31+
from .entities import Experiment, Variation
3232
from .helpers.types import TrafficAllocation
3333

3434

@@ -119,6 +119,34 @@ def bucket(
119119
and array of log messages representing decision making.
120120
*/.
121121
"""
122+
variation_id, decide_reasons = self.bucket_to_entity_id(project_config, experiment, user_id, bucketing_id)
123+
if variation_id:
124+
variation = project_config.get_variation_from_id_by_experiment_id(experiment.id, variation_id)
125+
return variation, decide_reasons
126+
127+
elif not decide_reasons:
128+
message = 'Bucketed into an empty traffic range. Returning nil.'
129+
project_config.logger.info(message)
130+
decide_reasons.append(message)
131+
132+
return None, decide_reasons
133+
134+
def bucket_to_entity_id(
135+
self, project_config: ProjectConfig,
136+
experiment: Experiment, user_id: str, bucketing_id: str
137+
) -> tuple[Optional[str], list[str]]:
138+
"""
139+
For a given experiment and bucketing ID determines variation ID to be shown to user.
140+
141+
Args:
142+
project_config: Instance of ProjectConfig.
143+
experiment: The experiment object (used for group/groupPolicy logic if needed).
144+
user_id: The user ID string.
145+
bucketing_id: The bucketing ID string for the user.
146+
147+
Returns:
148+
Tuple of (entity_id or None, list of decide reasons).
149+
"""
122150
decide_reasons: list[str] = []
123151
if not experiment:
124152
return None, decide_reasons
@@ -154,77 +182,5 @@ def bucket(
154182
# Bucket user if not in white-list and in group (if any)
155183
variation_id = self.find_bucket(project_config, bucketing_id,
156184
experiment.id, experiment.trafficAllocation)
157-
if variation_id:
158-
variation = project_config.get_variation_from_id_by_experiment_id(experiment.id, variation_id)
159-
return variation, decide_reasons
160-
161-
else:
162-
message = 'Bucketed into an empty traffic range. Returning nil.'
163-
project_config.logger.info(message)
164-
decide_reasons.append(message)
165-
166-
return None, decide_reasons
167-
168-
def bucket_to_entity_id(
169-
self,
170-
bucketing_id: str,
171-
experiment: Experiment,
172-
traffic_allocations: list[TrafficAllocation],
173-
group: Optional[Group] = None
174-
) -> tuple[Optional[str], list[str]]:
175-
"""
176-
Buckets the user and returns the entity ID (for CMAB experiments).
177-
Args:
178-
bucketing_id: The bucketing ID string for the user.
179-
experiment: The experiment object (for group/groupPolicy logic if needed).
180-
traffic_allocations: List of traffic allocation dicts (should have 'entity_id' and 'end_of_range' keys).
181-
group: (optional) Group object for mutex group support.
182185

183-
Returns:
184-
Tuple of (entity_id or None, list of decide reasons).
185-
"""
186-
decide_reasons = []
187-
188-
group_id = getattr(experiment, 'groupId', None)
189-
if group_id and group and getattr(group, 'policy', None) == 'random':
190-
bucket_key = bucketing_id + group_id
191-
bucket_val = self._generate_bucket_value(bucket_key)
192-
decide_reasons.append(f'Generated group bucket value {bucket_val} for key "{bucket_key}".')
193-
194-
matched = False
195-
for allocation in group.trafficAllocation:
196-
end_of_range = allocation['endOfRange']
197-
entity_id = allocation['entityId']
198-
if bucket_val < end_of_range:
199-
matched = True
200-
if entity_id != experiment.id:
201-
decide_reasons.append(
202-
f'User not bucketed into experiment "{experiment.id}" (got "{entity_id}").'
203-
)
204-
return None, decide_reasons
205-
decide_reasons.append(
206-
f'User is bucketed into experiment "{experiment.id}" within group "{group_id}".'
207-
)
208-
break
209-
if not matched:
210-
decide_reasons.append(
211-
f'User not bucketed into any experiment in group "{group_id}".'
212-
)
213-
return None, decide_reasons
214-
215-
# Main experiment bucketing
216-
bucket_key = bucketing_id + experiment.id
217-
bucket_val = self._generate_bucket_value(bucket_key)
218-
decide_reasons.append(f'Generated experiment bucket value {bucket_val} for key "{bucket_key}".')
219-
220-
for allocation in traffic_allocations:
221-
end_of_range = allocation['endOfRange']
222-
entity_id = allocation['entityId']
223-
if bucket_val < end_of_range:
224-
decide_reasons.append(
225-
f'User bucketed into entity id "{entity_id}".'
226-
)
227-
return entity_id, decide_reasons
228-
229-
decide_reasons.append('User not bucketed into any entity id.')
230-
return None, decide_reasons
186+
return variation_id, decide_reasons

optimizely/decision/optimizely_decision.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,23 @@ def as_json(self) -> dict[str, Any]:
4848
'user_context': self.user_context.as_json() if self.user_context else None,
4949
'reasons': self.reasons
5050
}
51+
52+
@classmethod
53+
def new_error_decision(cls, key: str, user: OptimizelyUserContext, reasons: list[str]) -> OptimizelyDecision:
54+
"""Create a new OptimizelyDecision representing an error state.
55+
Args:
56+
key: The flag key
57+
user: The user context
58+
reasons: List of reasons explaining the error
59+
Returns:
60+
OptimizelyDecision with error state values
61+
"""
62+
return cls(
63+
variation_key=None,
64+
enabled=False,
65+
variables={},
66+
rule_key=None,
67+
flag_key=key,
68+
user_context=user,
69+
reasons=[reasons[-1]] if reasons else []
70+
)

optimizely/decision_service.py

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
# prevent circular dependenacy by skipping import at runtime
3131
from .project_config import ProjectConfig
3232
from .logger import Logger
33-
from .helpers.types import TrafficAllocation
3433

3534

3635
class CmabDecisionResult(TypedDict):
@@ -134,6 +133,7 @@ def _get_decision_for_cmab_experiment(
134133
project_config: ProjectConfig,
135134
experiment: entities.Experiment,
136135
user_context: OptimizelyUserContext,
136+
bucketing_id: str,
137137
options: Optional[Sequence[str]] = None
138138
) -> CmabDecisionResult:
139139
"""
@@ -143,14 +143,36 @@ def _get_decision_for_cmab_experiment(
143143
project_config: Instance of ProjectConfig.
144144
experiment: The experiment object for which the decision is to be made.
145145
user_context: The user context containing user id and attributes.
146+
bucketing_id: The bucketing ID to use for traffic allocation.
146147
options: Optional sequence of decide options.
147148
148149
Returns:
149150
A dictionary containing:
150151
- "error": Boolean indicating if there was an error.
151-
- "result": The CmabDecision result or empty dict if error.
152+
- "result": The CmabDecision result or None if error.
152153
- "reasons": List of strings with reasons or error messages.
153154
"""
155+
decide_reasons: list[str] = []
156+
user_id = user_context.user_id
157+
158+
# Check if user is in CMAB traffic allocation
159+
bucketed_entity_id, bucket_reasons = self.bucketer.bucket_to_entity_id(
160+
project_config, experiment, user_id, bucketing_id
161+
)
162+
decide_reasons.extend(bucket_reasons)
163+
164+
if not bucketed_entity_id:
165+
message = f'User "{user_context.user_id}" not in CMAB experiment ' \
166+
f'"{experiment.key}" due to traffic allocation.'
167+
self.logger.info(message)
168+
decide_reasons.append(message)
169+
return {
170+
"error": False,
171+
"result": None,
172+
"reasons": decide_reasons,
173+
}
174+
175+
# User is in CMAB allocation, proceed to CMAB decision
154176
try:
155177
options_list = list(options) if options is not None else []
156178
cmab_decision = self.cmab_service.get_decision(
@@ -159,7 +181,7 @@ def _get_decision_for_cmab_experiment(
159181
return {
160182
"error": False,
161183
"result": cmab_decision,
162-
"reasons": [],
184+
"reasons": decide_reasons,
163185
}
164186
except Exception as e:
165187
error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format(
@@ -468,49 +490,25 @@ def get_variation(
468490
# If so, handle CMAB-specific traffic allocation and decision logic.
469491
# Otherwise, proceed with standard bucketing logic for non-CMAB experiments.
470492
if experiment.cmab:
471-
CMAB_DUMMY_ENTITY_ID = "$"
472-
# Build the CMAB-specific traffic allocation
473-
cmab_traffic_allocation: list[TrafficAllocation] = [{
474-
"entityId": CMAB_DUMMY_ENTITY_ID,
475-
"endOfRange": experiment.cmab['trafficAllocation']
476-
}]
477-
478-
# Check if user is in CMAB traffic allocation
479-
group = None
480-
if experiment.groupId:
481-
group = project_config.get_group(group_id=experiment.groupId)
482-
bucketed_entity_id, bucket_reasons = self.bucketer.bucket_to_entity_id(
483-
bucketing_id, experiment, cmab_traffic_allocation, group
484-
)
485-
decide_reasons += bucket_reasons
486-
if bucketed_entity_id != CMAB_DUMMY_ENTITY_ID:
487-
message = f'User "{user_id}" not in CMAB experiment "{experiment.key}" due to traffic allocation.'
488-
self.logger.info(message)
489-
decide_reasons.append(message)
490-
return {
491-
'cmab_uuid': None,
492-
'error': False,
493-
'reasons': decide_reasons,
494-
'variation': None
495-
}
496-
497-
# User is in CMAB allocation, proceed to CMAB decision
493+
experiment.cmab
498494
cmab_decision_result = self._get_decision_for_cmab_experiment(project_config,
499495
experiment,
500496
user_context,
497+
bucketing_id,
501498
options)
502499
decide_reasons += cmab_decision_result.get('reasons', [])
503500
cmab_decision = cmab_decision_result.get('result')
504-
if not cmab_decision or cmab_decision_result['error']:
501+
if cmab_decision_result['error']:
505502
return {
506503
'cmab_uuid': None,
507504
'error': True,
508505
'reasons': decide_reasons,
509506
'variation': None
510507
}
511-
variation_id = cmab_decision['variation_id']
512-
cmab_uuid = cmab_decision['cmab_uuid']
513-
variation = project_config.get_variation_from_id(experiment_key=experiment.key, variation_id=variation_id)
508+
variation_id = cmab_decision['variation_id'] if cmab_decision else None
509+
cmab_uuid = cmab_decision['cmab_uuid'] if cmab_decision else None
510+
variation = project_config.get_variation_from_id(experiment_key=experiment.key,
511+
variation_id=variation_id) if variation_id else None
514512
else:
515513
# Bucket the user
516514
variation, bucket_reasons = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id)

optimizely/optimizely.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,14 +1369,19 @@ def _decide_for_keys(
13691369
user_context,
13701370
merged_decide_options
13711371
)
1372-
1372+
print("here")
13731373
for i in range(0, len(flags_without_forced_decision)):
13741374
decision = decision_list[i]['decision']
13751375
reasons = decision_list[i]['reasons']
1376-
# Can catch errors now. Not used as decision logic implicitly handles error decision.
1377-
# Will be required for impression events
1378-
# error = decision_list[i]['error']
1376+
error = decision_list[i]['error']
13791377
flag_key = flags_without_forced_decision[i].key
1378+
# store error decision against key and remove key from valid keys
1379+
if error:
1380+
optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key,
1381+
user_context, reasons)
1382+
decisions[flag_key] = optimizely_decision
1383+
if flag_key in valid_keys:
1384+
valid_keys.remove(flag_key)
13801385
flag_decisions[flag_key] = decision
13811386
decision_reasons_dict[flag_key] += reasons
13821387

0 commit comments

Comments
 (0)