Skip to content

Commit 38a09b8

Browse files
authored
fix(flags): preserve typed Redis fallback results (#812)
* fix(flags): preserve typed Redis fallback results * fix: keep Redis result type import private
1 parent 25b9d28 commit 38a09b8

6 files changed

Lines changed: 205 additions & 5 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: patch
3+
---
4+
5+
Preserve typed feature flag results in Redis fallback

‎posthog/client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2579,7 +2579,7 @@ def _get_stale_flag_fallback(
25792579
"""Returns a stale cached flag value if available, otherwise None."""
25802580
if self.flag_cache:
25812581
stale_result = self.flag_cache.get_stale_cached_flag(distinct_id, key)
2582-
if stale_result:
2582+
if isinstance(stale_result, FeatureFlagResult):
25832583
self.log.info(
25842584
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
25852585
)

‎posthog/test/test_client.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from posthog.request import APIError, GetResponse
1919
from posthog.test.logging_helpers import capture_message_only_logs
2020
from posthog.test.test_utils import FAKE_TEST_API_KEY
21-
from posthog.types import FeatureFlag, LegacyFlagMetadata
21+
from posthog.types import FeatureFlag, FeatureFlagResult, LegacyFlagMetadata
2222
from posthog.version import VERSION
2323
from posthog.contexts import tag
2424

@@ -198,7 +198,9 @@ def test_duplicate_client_warning_allows_intentional_multi_client_cases(
198198

199199
def test_message_only_info_logs_include_posthog_prefix(self):
200200
self.client.flag_cache = mock.Mock()
201-
self.client.flag_cache.get_stale_cached_flag.return_value = mock.Mock()
201+
self.client.flag_cache.get_stale_cached_flag.return_value = FeatureFlagResult(
202+
key="flag-key", enabled=True, variant=None, payload=None, reason=None
203+
)
202204

203205
with capture_message_only_logs(level=logging.INFO) as logs:
204206
self.client._get_stale_flag_fallback("distinct_id", "flag-key")

‎posthog/test/test_feature_flag_result.py‎

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
from unittest import mock
33

44
from posthog.client import Client
5-
from posthog.test.test_utils import FAKE_TEST_API_KEY
5+
from posthog.test.test_utils import FAKE_TEST_API_KEY, FakeRedis
66
from posthog.types import (
77
FeatureFlag,
88
FeatureFlagError,
99
FeatureFlagResult,
1010
FlagMetadata,
1111
FlagReason,
1212
)
13+
from posthog.utils import RedisFlagCache
1314

1415

1516
class TestFeatureFlagResult(unittest.TestCase):
@@ -740,6 +741,103 @@ def _populate_stale_cache(self, distinct_id, flag_key, flag_result):
740741
flag_definition_version=self.client.flag_definition_version,
741742
)
742743

744+
@mock.patch("posthog.client.flags")
745+
@mock.patch.object(Client, "capture")
746+
def test_redis_timeout_returns_stale_cached_value_and_event(
747+
self, patch_capture, patch_flags
748+
):
749+
"""Redis stale fallback reconstructs the result used by flag-called events."""
750+
from posthog.request import RequestsTimeout
751+
752+
self.client.flag_cache = RedisFlagCache(FakeRedis())
753+
cached_result = FeatureFlagResult(
754+
key="my-flag",
755+
enabled=True,
756+
variant="cached-variant",
757+
payload={"from": "redis"},
758+
reason="cached reason",
759+
)
760+
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
761+
patch_flags.side_effect = RequestsTimeout("Request timed out")
762+
763+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
764+
765+
self.assertEqual(flag_result, cached_result)
766+
patch_capture.assert_called_once_with(
767+
"$feature_flag_called",
768+
distinct_id="some-distinct-id",
769+
properties={
770+
"$feature_flag": "my-flag",
771+
"$feature_flag_response": "cached-variant",
772+
"locally_evaluated": False,
773+
"$feature/my-flag": "cached-variant",
774+
"$feature_flag_payload": {"from": "redis"},
775+
"$feature_flag_error": FeatureFlagError.TIMEOUT,
776+
},
777+
groups={},
778+
disable_geoip=None,
779+
)
780+
781+
@mock.patch("posthog.client.flags")
782+
@mock.patch.object(Client, "capture")
783+
def test_redis_timeout_returns_stale_cached_value_without_event(
784+
self, patch_capture, patch_flags
785+
):
786+
"""Redis stale fallback is reconstructed when event capture is disabled."""
787+
from posthog.request import RequestsTimeout
788+
789+
self.client.flag_cache = RedisFlagCache(FakeRedis())
790+
cached_result = FeatureFlagResult(
791+
key="my-flag",
792+
enabled=False,
793+
variant=None,
794+
payload={"from": "redis"},
795+
reason="cached reason",
796+
)
797+
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
798+
patch_flags.side_effect = RequestsTimeout("Request timed out")
799+
800+
flag_result = self.client.get_feature_flag_result(
801+
"my-flag", "some-distinct-id", send_feature_flag_events=False
802+
)
803+
804+
self.assertEqual(flag_result, cached_result)
805+
patch_capture.assert_not_called()
806+
807+
@mock.patch("posthog.client.flags")
808+
@mock.patch.object(Client, "capture")
809+
def test_legacy_redis_entry_is_safely_ignored(self, patch_capture, patch_flags):
810+
"""Unmarked entries from older SDKs are not returned as result dictionaries."""
811+
import json
812+
import time
813+
814+
from posthog.request import RequestsTimeout
815+
816+
redis = FakeRedis()
817+
self.client.flag_cache = RedisFlagCache(redis)
818+
cache_key = self.client.flag_cache._get_cache_key("some-distinct-id", "my-flag")
819+
redis.store[cache_key] = json.dumps(
820+
{
821+
"flag_result": {
822+
"key": "my-flag",
823+
"enabled": True,
824+
"variant": None,
825+
"payload": {"from": "legacy"},
826+
"reason": None,
827+
},
828+
"flag_version": self.client.flag_definition_version,
829+
"timestamp": time.time(),
830+
}
831+
)
832+
patch_flags.side_effect = RequestsTimeout("Request timed out")
833+
834+
flag_result = self.client.get_feature_flag_result(
835+
"my-flag", "some-distinct-id", send_feature_flag_events=False
836+
)
837+
838+
self.assertIsNone(flag_result)
839+
patch_capture.assert_not_called()
840+
743841
@mock.patch("posthog.client.flags")
744842
@mock.patch.object(Client, "capture")
745843
def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_flags):

‎posthog/test/test_utils.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,86 @@ def test_cache_key_and_serialization(self):
766766
assert self.cache._deserialize_entry("not json") is None
767767
assert self.cache._deserialize_entry(json.dumps({"flag_result": True})) is None
768768

769+
def test_feature_flag_result_round_trip(self):
770+
flag_result = FeatureFlagResult(
771+
key="checkout-redesign",
772+
enabled=True,
773+
variant="test",
774+
payload={"discount": Decimal("12.5")},
775+
reason="matched rollout condition",
776+
)
777+
778+
serialized = self.cache._serialize_entry(flag_result, 3, timestamp=123)
779+
780+
assert json.loads(serialized) == {
781+
"flag_result": {
782+
"key": "checkout-redesign",
783+
"enabled": True,
784+
"variant": "test",
785+
"payload": {"discount": 12.5},
786+
"reason": "matched rollout condition",
787+
},
788+
"flag_version": 3,
789+
"timestamp": 123,
790+
"flag_result_type": "FeatureFlagResult",
791+
"flag_result_schema_version": 1,
792+
}
793+
entry = self.cache._deserialize_entry(serialized)
794+
assert entry is not None
795+
assert entry.flag_result == FeatureFlagResult(
796+
key="checkout-redesign",
797+
enabled=True,
798+
variant="test",
799+
payload={"discount": 12.5},
800+
reason="matched rollout condition",
801+
)
802+
803+
@parameterized.expand(
804+
[
805+
("boolean", True),
806+
("variant", "test"),
807+
("dictionary", {"enabled": True, "payload": {"plan": "pro"}}),
808+
]
809+
)
810+
def test_untyped_flag_result_round_trip(self, _name, flag_result):
811+
serialized = self.cache._serialize_entry(flag_result, 3, timestamp=123)
812+
813+
assert "flag_result_type" not in json.loads(serialized)
814+
entry = self.cache._deserialize_entry(serialized)
815+
assert entry is not None
816+
assert entry.flag_result == flag_result
817+
818+
def test_legacy_unmarked_feature_flag_result_remains_a_dict(self):
819+
legacy_result = {
820+
"key": "checkout-redesign",
821+
"enabled": True,
822+
"variant": None,
823+
"payload": {"plan": "pro"},
824+
"reason": "legacy entry",
825+
}
826+
serialized = json.dumps(
827+
{"flag_result": legacy_result, "flag_version": 3, "timestamp": 123}
828+
)
829+
830+
entry = self.cache._deserialize_entry(serialized)
831+
832+
assert entry is not None
833+
assert entry.flag_result == legacy_result
834+
assert isinstance(entry.flag_result, dict)
835+
836+
def test_unknown_feature_flag_result_schema_is_a_cache_miss(self):
837+
serialized = json.dumps(
838+
{
839+
"flag_result": {},
840+
"flag_version": 3,
841+
"timestamp": 123,
842+
"flag_result_type": "FeatureFlagResult",
843+
"flag_result_schema_version": 2,
844+
}
845+
)
846+
847+
assert self.cache._deserialize_entry(serialized) is None
848+
769849
def test_get_set_and_stale_cached_flags(self):
770850
self.cache.set_cached_flag("user123", "beta", True, 7)
771851

‎posthog/utils.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import platform
1414
import distro # For Linux OS detection
1515

16+
from .types import FeatureFlagResult as _FeatureFlagResult
17+
1618
log = logging.getLogger("posthog")
1719

1820

@@ -165,6 +167,8 @@ def __setitem__(self, key, value):
165167
CACHE_TTL = 300
166168
CACHE_STALE_TTL = 3600
167169
CACHE_KEY_PREFIX = "posthog:flags:"
170+
_FEATURE_FLAG_RESULT_TYPE = "FeatureFlagResult"
171+
_FEATURE_FLAG_RESULT_SCHEMA_VERSION = 1
168172

169173

170174
class FlagCacheEntry:
@@ -320,18 +324,29 @@ def _serialize_entry(self, flag_result, flag_definition_version, timestamp=None)
320324
"flag_version": flag_definition_version,
321325
"timestamp": timestamp,
322326
}
327+
if isinstance(flag_result, _FeatureFlagResult):
328+
# Additive metadata keeps the existing entry shape readable by older SDKs.
329+
entry["flag_result_type"] = _FEATURE_FLAG_RESULT_TYPE
330+
entry["flag_result_schema_version"] = _FEATURE_FLAG_RESULT_SCHEMA_VERSION
323331
return json.dumps(entry)
324332

325333
def _deserialize_entry(self, data):
326334
try:
327335
entry = json.loads(data)
328336
flag_result = entry["flag_result"]
337+
if entry.get("flag_result_type") == _FEATURE_FLAG_RESULT_TYPE:
338+
if (
339+
entry.get("flag_result_schema_version")
340+
!= _FEATURE_FLAG_RESULT_SCHEMA_VERSION
341+
):
342+
return None
343+
flag_result = _FeatureFlagResult(**flag_result)
329344
return FlagCacheEntry(
330345
flag_result=flag_result,
331346
flag_definition_version=entry["flag_version"],
332347
timestamp=entry["timestamp"],
333348
)
334-
except (json.JSONDecodeError, KeyError, ValueError):
349+
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
335350
# If deserialization fails, treat as cache miss
336351
return None
337352

0 commit comments

Comments
 (0)