Skip to content

Commit 35220f3

Browse files
authored
fix(flags): fall back for missing local definitions (#878)
* fix(flags): fall back for missing local definitions * docs(flags): document missing-key fallback cost * docs(flags): clarify empty flag key scope
1 parent bd7a5c4 commit 35220f3

4 files changed

Lines changed: 103 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+
Fall back to remote evaluation when a requested flag is missing from local definitions. This changes the previous behavior where the key was omitted without a request.

‎posthog/__init__.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,12 +1074,15 @@ def evaluate_flags(
10741074
groups: Mapping of group type to group key.
10751075
person_properties: Person properties to use for evaluation.
10761076
group_properties: Group properties keyed by group type.
1077-
only_evaluate_locally: If ``True``, never fall back to remote evaluation.
1077+
only_evaluate_locally: If ``True``, never fall back to remote evaluation and
1078+
omit flags that cannot be evaluated locally.
10781079
disable_geoip: Whether to disable GeoIP lookup.
1079-
flag_keys: Optional list of flag keys. When provided, only these flags are
1080-
evaluated — the underlying ``/flags`` request asks the server for just
1081-
this subset, which makes the response smaller and the request cheaper.
1082-
Use this when you only need a handful of flags out of many.
1080+
flag_keys: Optional non-empty list that scopes local evaluation, the underlying
1081+
``/flags`` request, and the returned snapshot. An empty list is treated like ``None``
1082+
and evaluates all flags. A requested key absent from loaded local definitions is
1083+
included in one remote fallback per ``evaluate_flags`` call unless
1084+
``only_evaluate_locally`` is ``True``. If the server also does not know the key, it is
1085+
omitted from the snapshot.
10831086
device_id: Optional device ID override. If not provided, falls back to the
10841087
context device_id (which may be set via tracing headers). Used by
10851088
experience-continuity flags to match users across distinct_id changes.

‎posthog/client.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4044,8 +4044,12 @@ def evaluate_flags(
40444044
only_evaluate_locally: If True, never fall back to remote evaluation —
40454045
flags that can't be evaluated locally are simply omitted from the snapshot.
40464046
disable_geoip: Whether to disable GeoIP lookup.
4047-
flag_keys: Optional list of flag keys to scope the underlying ``/flags``
4048-
request to a subset.
4047+
flag_keys: Optional non-empty list that scopes local evaluation, the underlying
4048+
``/flags`` request, and the returned snapshot. An empty list is treated like
4049+
``None`` and evaluates all flags. A requested key absent from loaded local
4050+
definitions is included in one remote fallback per ``evaluate_flags`` call unless
4051+
``only_evaluate_locally`` is True. If the server also does not know the key, it is
4052+
omitted from the snapshot.
40494053
device_id: Optional device ID override. If not provided, falls back to the
40504054
context device_id (which may be set via tracing headers). Used by
40514055
experience-continuity flags to match users across distinct_id changes.
@@ -4092,6 +4096,8 @@ def evaluate_flags(
40924096
)
40934097
)
40944098
groups = groups or {}
4099+
# Keep the existing API convention that an empty list means no scope.
4100+
requested_keys = set(flag_keys) if flag_keys else None
40954101

40964102
records: Dict[str, _EvaluatedFlagRecord] = {}
40974103
request_id: Optional[str] = None
@@ -4121,6 +4127,11 @@ def evaluate_flags(
41214127
feature_flags_by_key: Dict[str, Any] = self.feature_flags_by_key or {}
41224128
local_flags = local_result.get("featureFlags") or {}
41234129
local_payloads = local_result.get("featureFlagPayloads") or {}
4130+
if requested_keys and not requested_keys.issubset(local_flags):
4131+
# A requested flag may have been created since the last definitions poll.
4132+
# Ask /flags for the caller's original scope unless this is a local-only call.
4133+
fallback_to_server = True
4134+
41244135
for key, value in local_flags.items():
41254136
flag_def = feature_flags_by_key.get(key) or {}
41264137
records[key] = _EvaluatedFlagRecord(
@@ -4164,6 +4175,8 @@ def evaluate_flags(
41644175
response.get("minimalFlagCalledEvents") is True
41654176
)
41664177
for key, detail in response.get("flags", {}).items():
4178+
if requested_keys is not None and key not in requested_keys:
4179+
continue
41674180
if key in locally_evaluated_keys:
41684181
continue
41694182
payload = _parse_flag_payload(

‎posthog/test/test_evaluate_flags.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,81 @@ def test_flag_called_event_carries_parsed_local_payload(
395395
self.assertEqual(properties["$feature_flag_payload"], {"copy": "new"})
396396

397397

398+
class TestEvaluateFlagsMissingLocalDefinition(unittest.TestCase):
399+
def setUp(self):
400+
self.client = Client(FAKE_TEST_API_KEY, secret_key="test")
401+
self.client.feature_flags = [
402+
{
403+
"id": 1,
404+
"name": "Local flag",
405+
"key": "local-flag",
406+
"active": True,
407+
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
408+
}
409+
]
410+
411+
def tearDown(self):
412+
self.client.shutdown()
413+
414+
@staticmethod
415+
def _remote_response():
416+
return {
417+
"flags": {
418+
"local-flag": {"enabled": False, "variant": None},
419+
"remote-only": {"enabled": True, "variant": None},
420+
"unrequested": {"enabled": True, "variant": None},
421+
}
422+
}
423+
424+
@mock.patch("posthog.client.flags")
425+
def test_missing_requested_key_triggers_one_scoped_fallback(self, patch_flags):
426+
patch_flags.return_value = self._remote_response()
427+
requested_keys = ["local-flag", "remote-only"]
428+
429+
flags = self.client.evaluate_flags("user-1", flag_keys=requested_keys)
430+
431+
self.assertEqual(set(flags.keys), set(requested_keys))
432+
self.assertTrue(flags.get_flag("local-flag"))
433+
self.assertTrue(flags.get_flag("remote-only"))
434+
patch_flags.assert_called_once()
435+
self.assertEqual(
436+
patch_flags.call_args.kwargs["flag_keys_to_evaluate"], requested_keys
437+
)
438+
439+
@mock.patch("posthog.client.flags")
440+
def test_missing_requested_key_is_omitted_for_local_only_evaluation(
441+
self, patch_flags
442+
):
443+
patch_flags.return_value = self._remote_response()
444+
445+
flags = self.client.evaluate_flags(
446+
"user-1",
447+
flag_keys=["local-flag", "remote-only"],
448+
only_evaluate_locally=True,
449+
)
450+
451+
self.assertEqual(flags.keys, ["local-flag"])
452+
self.assertTrue(flags.get_flag("local-flag"))
453+
self.assertIsNone(flags.get_flag("remote-only"))
454+
patch_flags.assert_not_called()
455+
456+
@mock.patch("posthog.client.flags")
457+
def test_server_missing_key_falls_back_once_per_evaluation_call(self, patch_flags):
458+
patch_flags.return_value = {"flags": {}}
459+
requested_keys = ["local-flag", "typo-flag"]
460+
461+
first = self.client.evaluate_flags("user-1", flag_keys=requested_keys)
462+
second = self.client.evaluate_flags("user-1", flag_keys=requested_keys)
463+
464+
for flags in (first, second):
465+
self.assertEqual(flags.keys, ["local-flag"])
466+
self.assertTrue(flags.get_flag("local-flag"))
467+
self.assertIsNone(flags.get_flag("typo-flag"))
468+
self.assertEqual(patch_flags.call_count, 2)
469+
for call in patch_flags.call_args_list:
470+
self.assertEqual(call.kwargs["flag_keys_to_evaluate"], requested_keys)
471+
472+
398473
class TestEvaluateFlagsLocalDeviceBucketing(unittest.TestCase):
399474
def setUp(self):
400475
self.client = Client(FAKE_TEST_API_KEY)

0 commit comments

Comments
 (0)