Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions growthbook/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1417,8 +1417,12 @@ def _getExperimentResult(
variationId = 0
inExperiment = False

# A meta list shorter than the variations list is a malformed payload:
# treat the missing entry as absent instead of raising. (The JS SDK
# crashes here too — reading `.key` off undefined — so this is a
# deliberate divergence on invalid payloads only.)
meta = None
if experiment.meta:
if experiment.meta and variationId < len(experiment.meta):
meta = experiment.meta[variationId]

(hashAttribute, hashValue) = _getOrigHashValue(attr=experiment.hashAttribute,
Expand All @@ -1436,11 +1440,18 @@ def _getExperimentResult(
variation_weights = cb["variationWeights"]
bandit_version = cb.get("banditVersion")

# The clamp above leaves variationId at 0 even when the variations list is
# empty (a malformed payload — e.g. `variations: []` or
# `contextualVariations: []`). The JS SDK reads `undefined` there and the
# rule degrades to the feature's default; mirror it with None instead of
# raising IndexError.
value = experiment.variations[variationId] if experiment.variations else None

return Result(
featureId=featureId,
inExperiment=inExperiment,
variationId=variationId,
value=experiment.variations[variationId],
value=value,
hashUsed=hashUsed,
hashAttribute=hashAttribute,
hashValue=hashValue,
Expand Down
56 changes: 56 additions & 0 deletions tests/test_growthbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,62 @@ def test_handles_weird_experiment_values():
gb.destroy()


def test_empty_variations_rule_serves_default():
"""A rule with an empty variations list is a malformed payload: the JS SDK
reads `undefined` as the clamped variation's value, marks the result not
in-experiment, and serves the feature default. Python used to raise an
uncaught IndexError instead."""
gb = GrowthBook(
attributes={"id": "1"},
features={"f": {"defaultValue": "x", "rules": [{"key": "exp", "variations": []}]}},
)
res = gb.eval_feature("f")
assert res.value == "x"
assert res.source == "defaultValue"

# Same guard for the public run() API.
result = gb.run(Experiment(key="direct", variations=[]))
assert result.inExperiment is False
assert result.value is None
gb.destroy()


def test_empty_contextual_variations_rule_serves_default():
"""Same malformed shape through the contextual bandit door
(`contextualVariations: []`)."""
gb = GrowthBook(
attributes={"id": "1"},
features={"f": {"defaultValue": "x", "rules": [{
"key": "exp", "seed": "s", "hashVersion": 2, "hashAttribute": "id",
"contextualVariations": [], "weights": [], "contextualBanditRef": "cb1",
}]}},
contextualBandits={"cb1": {"contexts": []}},
)
res = gb.eval_feature("f")
assert res.value == "x"
assert res.source == "defaultValue"
gb.destroy()


def test_meta_shorter_than_variations_does_not_crash():
"""A meta list shorter than the variations list is a malformed payload:
the missing entry reads as absent (result key falls back to the variation
id) instead of raising IndexError. Deliberately stricter than the JS SDK,
which crashes reading `.key` off undefined here."""
gb = GrowthBook(
attributes={"id": "1"},
features={"m": {"defaultValue": "x", "rules": [{
"key": "exp-meta", "variations": ["a", "b"], "weights": [0, 1],
"meta": [{"key": "control"}],
}]}},
)
res = gb.eval_feature("m")
assert res.value == "b"
assert res.experimentResult.variationId == 1
assert res.experimentResult.key == "1"
gb.destroy()


def test_experiment_to_dict_preserves_explicit_zero_coverage():
# `or 1` would coerce a real coverage of 0 to 1; None still reads as
# full coverage. Serialized dicts are forwarded (deferred tracking),
Expand Down
Loading