Skip to content

Commit a2ce51e

Browse files
feat(feature-flags): support early_exit in local evaluation (#648)
* feat(feature-flags): support early_exit in local evaluation Port PostHog/posthog-js#3705 to posthog-python. When a flag enables `filters.early_exit`, condition evaluation now stops and returns `False` as soon as a condition group's property filters match but the rollout percentage excludes the user, instead of falling through to later groups — matching the server-side (Rust) engine's `OutOfRolloutBound` short-circuit. - Introduce a `ConditionMatch` tri-state (MATCH / NO_MATCH / OUT_OF_ROLLOUT_BOUND) returned by `is_condition_match`, so the loop can distinguish a rollout exclusion from a property mismatch. Property mismatches still fall through, mirroring the Rust semantics exactly. - Read `filters.early_exit` in `match_feature_flag_properties` and short-circuit to `False` on OUT_OF_ROLLOUT_BOUND when enabled. - Tests for early-exit on, default off (regression), explicit off, rollout-only groups, and the property-mismatch case. Generated-By: PostHog Code Task-Id: 707b13a5-0e5d-4764-915a-21e1f2a80c63 * fix(feature-flags): remove redundant or False and add multivariate early_exit test
1 parent 1117c5e commit a2ce51e

3 files changed

Lines changed: 217 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
feat(feature-flags): support the `early_exit` condition option in local evaluation. When a flag enables early exit, evaluation now stops and returns `False` as soon as a condition group's property filters match but the rollout percentage excludes the user, instead of falling through to later groups — matching the server-side evaluation behavior.

posthog/feature_flags.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import re
66
import warnings
7+
from enum import Enum
78
from typing import Optional
89

910
from posthog import utils
@@ -16,6 +17,20 @@
1617

1718
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
1819

20+
21+
class ConditionMatch(Enum):
22+
"""Outcome of evaluating a single condition group.
23+
24+
OUT_OF_ROLLOUT_BOUND means the group's property filters matched (or there were none)
25+
but the rollout percentage excluded the user — the only case that triggers a flag's
26+
``early_exit`` short-circuit. Mirrors the server-side (Rust) engine's match cases.
27+
"""
28+
29+
MATCH = "match"
30+
NO_MATCH = "no_match"
31+
OUT_OF_ROLLOUT_BOUND = "out_of_rollout_bound"
32+
33+
1934
# All operators supported by match_property, grouped by category.
2035
EQUALITY_OPERATORS = ("exact", "is_not", "is_set", "is_not_set")
2136
STRING_OPERATORS = ("icontains", "not_icontains", "regex", "not_regex")
@@ -307,6 +322,7 @@ def match_feature_flag_properties(
307322
flag_filters = flag.get("filters") or {}
308323
flag_conditions = flag_filters.get("groups") or []
309324
flag_aggregation = flag_filters.get("aggregation_group_type_index")
325+
early_exit_enabled = flag_filters.get("early_exit")
310326
is_inconclusive = False
311327
cohort_properties = cohort_properties or {}
312328
groups = groups or {}
@@ -349,7 +365,7 @@ def match_feature_flag_properties(
349365
effective_properties = properties
350366
effective_bucketing = bucketing_value
351367

352-
if is_condition_match(
368+
match_result = is_condition_match(
353369
flag,
354370
distinct_id,
355371
condition,
@@ -359,13 +375,22 @@ def match_feature_flag_properties(
359375
evaluation_cache,
360376
bucketing_value=effective_bucketing,
361377
device_id=device_id,
362-
):
378+
)
379+
if match_result == ConditionMatch.MATCH:
363380
variant_override = condition.get("variant")
364381
if variant_override and variant_override in valid_variant_keys:
365382
variant = variant_override
366383
else:
367384
variant = get_matching_variant(flag, effective_bucketing)
368385
return variant or True
386+
elif (
387+
early_exit_enabled
388+
and match_result == ConditionMatch.OUT_OF_ROLLOUT_BOUND
389+
):
390+
# The condition's property filters (if any) matched and only the rollout check
391+
# failed, so re-evaluating later groups can't change the outcome. Return a
392+
# deterministic False, mirroring the server-side engine.
393+
return False
369394
except RequiresServerEvaluation:
370395
# Static cohort or other missing server-side data - must fallback to API
371396
raise
@@ -395,7 +420,7 @@ def is_condition_match(
395420
*,
396421
bucketing_value,
397422
device_id=None,
398-
) -> bool:
423+
) -> ConditionMatch:
399424
rollout_percentage = condition.get("rollout_percentage")
400425
if len(condition.get("properties") or []) > 0:
401426
for prop in condition.get("properties"):
@@ -423,17 +448,19 @@ def is_condition_match(
423448
else:
424449
matches = match_property(prop, properties)
425450
if not matches:
426-
return False
451+
return ConditionMatch.NO_MATCH
427452

428453
if rollout_percentage is None:
429-
return True
454+
return ConditionMatch.MATCH
430455

456+
# Property filters (if any) matched; only the rollout check remains. A failure here means
457+
# the user was targeted but excluded by rollout — the server-side engine's OutOfRolloutBound.
431458
if rollout_percentage is not None and _hash(
432459
feature_flag["key"], bucketing_value
433460
) > (rollout_percentage / 100):
434-
return False
461+
return ConditionMatch.OUT_OF_ROLLOUT_BOUND
435462

436-
return True
463+
return ConditionMatch.MATCH
437464

438465

439466
def match_property(property, property_values) -> bool:

posthog/test/test_feature_flags.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,184 @@ def test_case_insensitive_matching(self):
141141
)
142142
)
143143

144+
@parameterized.expand(
145+
[
146+
# (description, early_exit value, expected result)
147+
("enabled", True, False),
148+
("not set", None, True),
149+
("explicitly disabled", False, True),
150+
]
151+
)
152+
def test_early_exit(self, _name, early_exit, expected):
153+
# First group's properties match but its rollout (0%) excludes everyone; the second
154+
# group would otherwise match. Mirrors the server-side OutOfRolloutBound short-circuit.
155+
filters = {
156+
"groups": [
157+
{
158+
"properties": [
159+
{
160+
"key": "region",
161+
"operator": "exact",
162+
"value": ["USA"],
163+
"type": "person",
164+
}
165+
],
166+
"rollout_percentage": 0,
167+
},
168+
{
169+
"properties": [
170+
{
171+
"key": "region",
172+
"operator": "exact",
173+
"value": ["USA"],
174+
"type": "person",
175+
}
176+
],
177+
"rollout_percentage": 100,
178+
},
179+
],
180+
}
181+
if early_exit is not None:
182+
filters["early_exit"] = early_exit
183+
184+
self.client.feature_flags = [
185+
{
186+
"id": 1,
187+
"name": "Early Exit Feature",
188+
"key": "early-exit-flag",
189+
"active": True,
190+
"filters": filters,
191+
}
192+
]
193+
194+
self.assertEqual(
195+
self.client.get_feature_flag(
196+
"early-exit-flag",
197+
"some-distinct-id",
198+
person_properties={"region": "USA"},
199+
),
200+
expected,
201+
)
202+
203+
def test_early_exit_on_rollout_only_group_with_no_property_filters(self):
204+
self.client.feature_flags = [
205+
{
206+
"id": 1,
207+
"name": "Early Exit Feature",
208+
"key": "early-exit-flag",
209+
"active": True,
210+
"filters": {
211+
"early_exit": True,
212+
"groups": [
213+
{"rollout_percentage": 0},
214+
{"rollout_percentage": 100},
215+
],
216+
},
217+
}
218+
]
219+
220+
self.assertFalse(
221+
self.client.get_feature_flag("early-exit-flag", "some-distinct-id")
222+
)
223+
224+
def test_early_exit_does_not_trigger_on_property_mismatch(self):
225+
# First group fails on its property (region mismatch), not rollout — so even with
226+
# early_exit enabled, evaluation must continue to the second group, which matches.
227+
self.client.feature_flags = [
228+
{
229+
"id": 1,
230+
"name": "Early Exit Feature",
231+
"key": "early-exit-flag",
232+
"active": True,
233+
"filters": {
234+
"early_exit": True,
235+
"groups": [
236+
{
237+
"properties": [
238+
{
239+
"key": "region",
240+
"operator": "exact",
241+
"value": ["Canada"],
242+
"type": "person",
243+
}
244+
],
245+
"rollout_percentage": 0,
246+
},
247+
{
248+
"properties": [
249+
{
250+
"key": "region",
251+
"operator": "exact",
252+
"value": ["USA"],
253+
"type": "person",
254+
}
255+
],
256+
"rollout_percentage": 100,
257+
},
258+
],
259+
},
260+
}
261+
]
262+
263+
self.assertTrue(
264+
self.client.get_feature_flag(
265+
"early-exit-flag",
266+
"some-distinct-id",
267+
person_properties={"region": "USA"},
268+
)
269+
)
270+
271+
def test_early_exit_on_multivariate_flag(self):
272+
self.client.feature_flags = [
273+
{
274+
"id": 1,
275+
"name": "Early Exit Multivariate",
276+
"key": "early-exit-multivariate",
277+
"active": True,
278+
"filters": {
279+
"early_exit": True,
280+
"multivariate": {
281+
"variants": [
282+
{"key": "control", "rollout_percentage": 50},
283+
{"key": "test", "rollout_percentage": 50},
284+
]
285+
},
286+
"groups": [
287+
{
288+
"properties": [
289+
{
290+
"key": "region",
291+
"operator": "exact",
292+
"value": ["USA"],
293+
"type": "person",
294+
}
295+
],
296+
"rollout_percentage": 0,
297+
},
298+
{
299+
"properties": [
300+
{
301+
"key": "region",
302+
"operator": "exact",
303+
"value": ["USA"],
304+
"type": "person",
305+
}
306+
],
307+
"rollout_percentage": 100,
308+
},
309+
],
310+
},
311+
}
312+
]
313+
314+
self.assertFalse(
315+
self.client.get_feature_flag(
316+
"early-exit-multivariate",
317+
"some-distinct-id",
318+
person_properties={"region": "USA"},
319+
)
320+
)
321+
144322
@mock.patch("posthog.client.flags")
145323
@mock.patch("posthog.client.get")
146324
def test_flag_group_properties(self, patch_get, patch_flags):

0 commit comments

Comments
 (0)