diff --git a/.sampo/changesets/clear-flag-definition-etag-on-reset.md b/.sampo/changesets/clear-flag-definition-etag-on-reset.md new file mode 100644 index 00000000..2437cc9e --- /dev/null +++ b/.sampo/changesets/clear-flag-definition-etag-on-reset.md @@ -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. diff --git a/posthog/client.py b/posthog/client.py index 9fff1a5b..b237cd37 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -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: @@ -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() @@ -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: diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 497069ef..43da82c7 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -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):