From 31920582b9333b0ce4751780f0a79c085c9f078c Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 8 Sep 2026 14:08:19 -0700 Subject: [PATCH] fix: empty variations or short meta in a rule crashed eval with IndexError --- growthbook/core.py | 15 +++++++++-- tests/test_growthbook.py | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/growthbook/core.py b/growthbook/core.py index 01254fe..d8ddfea 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -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, @@ -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, diff --git a/tests/test_growthbook.py b/tests/test_growthbook.py index 4c161e5..f6bd1d8 100644 --- a/tests/test_growthbook.py +++ b/tests/test_growthbook.py @@ -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),