From fe443a6fff3d27d4101986a3cee0cca236de27ed Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sun, 31 May 2026 16:02:46 +0200 Subject: [PATCH 1/2] fix: no-op flag helpers on API errors --- .sampo/changesets/noop-flag-api-errors.md | 5 +++++ posthog/client.py | 16 ++++++++++------ posthog/test/test_client.py | 13 +++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 .sampo/changesets/noop-flag-api-errors.md diff --git a/.sampo/changesets/noop-flag-api-errors.md b/.sampo/changesets/noop-flag-api-errors.md new file mode 100644 index 00000000..ec6f73b4 --- /dev/null +++ b/.sampo/changesets/noop-flag-api-errors.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Return empty flag defaults from Client flag helpers when the flags API fails. diff --git a/posthog/client.py b/posthog/client.py index 5a3bd85a..a3135bc5 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -642,12 +642,16 @@ def get_flags_decision( if flag_keys_to_evaluate: request_data["flag_keys_to_evaluate"] = flag_keys_to_evaluate - resp_data = flags( - self.api_key, - self.host, - timeout=self.feature_flags_request_timeout_seconds, - **request_data, - ) + try: + resp_data = flags( + self.api_key, + self.host, + timeout=self.feature_flags_request_timeout_seconds, + **request_data, + ) + except Exception as err: + self.log.exception("Unable to get feature flags: %s", err) + return normalize_flags_response({}) return normalize_flags_response(resp_data) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 5275eb6c..62203b8e 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -106,6 +106,19 @@ def test_disabled_client_does_not_get_flags_decision(self, patch_flags): ) patch_flags.assert_not_called() + @mock.patch("posthog.client.flags") + def test_client_flag_helpers_return_defaults_on_api_error(self, patch_flags): + patch_flags.side_effect = APIError(401, "Unauthorized") + client = Client(FAKE_TEST_API_KEY, send=False) + + self.assertEqual(client.get_flags_decision("distinct_id")["flags"], {}) + self.assertEqual(client.get_feature_variants("distinct_id"), {}) + self.assertEqual(client.get_feature_payloads("distinct_id"), {}) + self.assertEqual( + client.get_feature_flags_and_payloads("distinct_id"), + {"featureFlags": {}, "featureFlagPayloads": {}}, + ) + def test_empty_flush(self): self.client.flush() From ac6b054a9ae29b5570dfa024a08aedafa711cf2a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 1 Jun 2026 08:24:52 +0200 Subject: [PATCH 2/2] fix: preserve flag error handling on flag API failures --- posthog/client.py | 48 ++++++++++++++++++++++++++----------- posthog/test/test_client.py | 33 +++++++++++++++++++------ 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index a3135bc5..d2cccc26 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -611,6 +611,30 @@ def get_flags_decision( Category: Feature flags """ + try: + return self._get_flags_decision( + distinct_id, + groups, + person_properties, + group_properties, + disable_geoip, + flag_keys_to_evaluate, + device_id=device_id, + ) + except Exception as err: + self.log.exception("Unable to get feature flags: %s", err) + return normalize_flags_response({}) + + def _get_flags_decision( + self, + distinct_id: Optional[ID_TYPES] = None, + groups: Optional[dict] = None, + person_properties=None, + group_properties=None, + disable_geoip=None, + flag_keys_to_evaluate: Optional[list[str]] = None, + device_id: Optional[str] = None, + ) -> FlagsResponse: if self.disabled: return normalize_flags_response({}) @@ -642,16 +666,12 @@ def get_flags_decision( if flag_keys_to_evaluate: request_data["flag_keys_to_evaluate"] = flag_keys_to_evaluate - try: - resp_data = flags( - self.api_key, - self.host, - timeout=self.feature_flags_request_timeout_seconds, - **request_data, - ) - except Exception as err: - self.log.exception("Unable to get feature flags: %s", err) - return normalize_flags_response({}) + resp_data = flags( + self.api_key, + self.host, + timeout=self.feature_flags_request_timeout_seconds, + **request_data, + ) return normalize_flags_response(resp_data) @@ -2179,7 +2199,7 @@ def _get_feature_flag_details_from_server( Calls /flags and returns the flag details, request id, evaluated at timestamp, and whether there were errors while computing flags. """ - resp_data = self.get_flags_decision( + resp_data = self._get_flags_decision( distinct_id, groups, person_properties, @@ -2451,7 +2471,7 @@ def get_all_flags_and_payloads( if fallback_to_flags and not only_evaluate_locally: try: - decide_response = self.get_flags_decision( + decide_response = self._get_flags_decision( distinct_id, groups=groups, person_properties=person_properties, @@ -2588,11 +2608,11 @@ def evaluate_flags( locally_evaluated_keys.add(key) # Fall back to remote evaluation for any flags the poller couldn't resolve locally. - # Use ``get_flags_decision`` directly so the resulting records carry id/version/reason + # Use the flags decision path directly so the resulting records carry id/version/reason # and fired ``$feature_flag_called`` events match what ``get_feature_flag()`` emits. if fallback_to_server and not only_evaluate_locally: try: - response = self.get_flags_decision( + response = self._get_flags_decision( distinct_id, groups=groups, person_properties=person_properties, diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 62203b8e..4fac43c8 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -111,13 +111,32 @@ def test_client_flag_helpers_return_defaults_on_api_error(self, patch_flags): patch_flags.side_effect = APIError(401, "Unauthorized") client = Client(FAKE_TEST_API_KEY, send=False) - self.assertEqual(client.get_flags_decision("distinct_id")["flags"], {}) - self.assertEqual(client.get_feature_variants("distinct_id"), {}) - self.assertEqual(client.get_feature_payloads("distinct_id"), {}) - self.assertEqual( - client.get_feature_flags_and_payloads("distinct_id"), - {"featureFlags": {}, "featureFlagPayloads": {}}, - ) + test_cases = [ + ( + "get_flags_decision", + lambda: client.get_flags_decision("distinct_id")["flags"], + {}, + ), + ( + "get_feature_variants", + lambda: client.get_feature_variants("distinct_id"), + {}, + ), + ( + "get_feature_payloads", + lambda: client.get_feature_payloads("distinct_id"), + {}, + ), + ( + "get_feature_flags_and_payloads", + lambda: client.get_feature_flags_and_payloads("distinct_id"), + {"featureFlags": {}, "featureFlagPayloads": {}}, + ), + ] + + for method_name, call_helper, expected in test_cases: + with self.subTest(method=method_name): + self.assertEqual(call_helper(), expected) def test_empty_flush(self): self.client.flush()