Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .sampo/changesets/clear-flag-definition-etag-on-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Local evaluation now clears the stored flag-definition ETag whenever it drops its definitions after a quota-limited (402) or unauthorized (401) response. Previously the ETag survived the reset, so the next poll asked the server conditionally for definitions the SDK no longer held, got a `304 Not Modified`, and left local evaluation with an empty definition set until the flag definitions happened to change server-side.
21 changes: 15 additions & 6 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2236,6 +2236,19 @@ def _update_flag_state(
self.flag_definition_version += 1
self.flag_cache.invalidate_version(old_version)

def _reset_flag_definitions(self) -> None:
"""Drop the loaded flag definitions and the conditional-fetch validator.

The ETag has to go with the definitions it describes: if we keep it, the
next poll sends `If-None-Match` for definitions we no longer hold, the
server answers 304, and local evaluation stays empty until the
definitions happen to change server-side.
"""
self.feature_flags = []
self.group_type_mapping = {}
self.cohorts = {}
self._flags_etag = None

def _load_feature_flags(self):
should_fetch = True
if self._flag_definition_cache_provider:
Expand Down Expand Up @@ -2346,9 +2359,7 @@ def _fetch_feature_flags_from_api(self):
"More information: https://posthog.com/docs/api/overview"
)
self.log.error("[FEATURE FLAGS] %s", detail)
self.feature_flags = []
self.group_type_mapping = {}
self.cohorts = {}
self._reset_flag_definitions()

if self.flag_cache:
self.flag_cache.clear()
Expand All @@ -2360,9 +2371,7 @@ def _fetch_feature_flags_from_api(self):
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
)
# Reset all feature flag data when quota limited
self.feature_flags = []
self.group_type_mapping = {}
self.cohorts = {}
self._reset_flag_definitions()

# Clear flag cache when quota limited
if self.flag_cache:
Expand Down
51 changes: 51 additions & 0 deletions posthog/test/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -3266,6 +3266,57 @@ def test_load_feature_flags_clears_etag_when_server_stops_sending(
self.assertIsNone(client._flags_etag)
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")

@parameterized.expand(
[
("quota_limited", 402, "quota limited"),
("unauthorized", 401, "Unauthorized"),
]
)
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_clears_etag_when_definitions_are_reset(
self, _name, status, message, patch_get, _patch_poll
):
"""A reset drops the ETag too, so the next poll can repopulate definitions.

Keeping the ETag would make the next request conditional on definitions
we no longer hold: the server answers 304 and local evaluation stays
empty until the definitions change server-side.
"""
patch_get.side_effect = [
GetResponse(
data={
"flags": [{"id": 1, "key": "flag-v1", "active": True}],
"group_type_mapping": {},
"cohorts": {},
},
etag='"etag-v1"',
),
APIError(status, message),
GetResponse(
data={
"flags": [{"id": 1, "key": "flag-v1", "active": True}],
"group_type_mapping": {},
"cohorts": {},
},
etag='"etag-v1"',
),
]

client = Client(FAKE_TEST_API_KEY, secret_key="test")
client.load_feature_flags()
self.assertEqual(client._flags_etag, '"etag-v1"')

client._load_feature_flags()
self.assertEqual(client.feature_flags, [])
self.assertIsNone(client._flags_etag)

# The recovery poll must be unconditional, otherwise a 304 leaves us empty.
client._load_feature_flags()
self.assertIsNone(patch_get.call_args_list[2].kwargs.get("etag"))
self.assertEqual(client.feature_flags[0]["key"], "flag-v1")
self.assertEqual(client._flags_etag, '"etag-v1"')

@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll):
Expand Down