From 20afffc4678b0a8c4739066830af85e2be5d360d Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 13 Feb 2026 13:18:03 +0100 Subject: [PATCH 1/6] test(flags): make wrong-key load_feature_flags deterministic --- posthog/test/test_feature_flags.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 783793f8..213ba8d2 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -2513,7 +2513,10 @@ 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") - def test_load_feature_flags_wrong_key(self): + @mock.patch("posthog.client.Poller") + @mock.patch("posthog.client.get") + def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll): + patch_get.side_effect = APIError(401, "Unauthorized") client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) with self.assertLogs("posthog", level="ERROR") as logs: From 899360efc3ce9c1537259065e0f645660fe702a1 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 13 Feb 2026 13:26:21 +0100 Subject: [PATCH 2/6] fix(utils): enforce SizeLimitedDict cap on missing-key inserts --- posthog/test/test_size_limited_dict.py | 19 +++++++++++++++++++ posthog/utils.py | 10 ++++++++++ 2 files changed, 29 insertions(+) diff --git a/posthog/test/test_size_limited_dict.py b/posthog/test/test_size_limited_dict.py index 3cef2137..0507bc26 100644 --- a/posthog/test/test_size_limited_dict.py +++ b/posthog/test/test_size_limited_dict.py @@ -22,3 +22,22 @@ def test_size_limited_dict(self, size: int, iterations: int) -> None: self.assertIsNone(values.get(i - 3)) self.assertIsNone(values.get(i - 5)) self.assertIsNone(values.get(i - 9)) + + @parameterized.expand([(10, 100), (5, 20), (20, 200)]) + def test_size_limited_dict_missing_key_population( + self, size: int, iterations: int + ) -> None: + values = utils.SizeLimitedDict(size, set) + + for i in range(iterations): + values[i].add(i) + + assert i in values[i] + assert len(values) == i % size + 1 + + if i % size == 0: + # old numbers should've been removed + self.assertIsNone(values.get(i - 1)) + self.assertIsNone(values.get(i - 3)) + self.assertIsNone(values.get(i - 5)) + self.assertIsNone(values.get(i - 9)) diff --git a/posthog/utils.py b/posthog/utils.py index 37f4a136..5a65e8ad 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -158,6 +158,16 @@ def __setitem__(self, key, value): super().__setitem__(key, value) + def __missing__(self, key): + if self.default_factory is None: + raise KeyError(key) + + value = self.default_factory() + # Route through __setitem__ so size limits are enforced consistently + # even when defaultdict populates missing keys. + self[key] = value + return value + class FlagCacheEntry: def __init__(self, flag_result, flag_definition_version, timestamp=None): From 7c75f3349390f59a737a3f2e3ebf24cddcf5e2d5 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 13 Feb 2026 14:12:27 +0100 Subject: [PATCH 3/6] Revert "fix(utils): enforce SizeLimitedDict cap on missing-key inserts" This reverts commit 899360efc3ce9c1537259065e0f645660fe702a1. --- posthog/test/test_size_limited_dict.py | 19 ------------------- posthog/utils.py | 10 ---------- 2 files changed, 29 deletions(-) diff --git a/posthog/test/test_size_limited_dict.py b/posthog/test/test_size_limited_dict.py index 0507bc26..3cef2137 100644 --- a/posthog/test/test_size_limited_dict.py +++ b/posthog/test/test_size_limited_dict.py @@ -22,22 +22,3 @@ def test_size_limited_dict(self, size: int, iterations: int) -> None: self.assertIsNone(values.get(i - 3)) self.assertIsNone(values.get(i - 5)) self.assertIsNone(values.get(i - 9)) - - @parameterized.expand([(10, 100), (5, 20), (20, 200)]) - def test_size_limited_dict_missing_key_population( - self, size: int, iterations: int - ) -> None: - values = utils.SizeLimitedDict(size, set) - - for i in range(iterations): - values[i].add(i) - - assert i in values[i] - assert len(values) == i % size + 1 - - if i % size == 0: - # old numbers should've been removed - self.assertIsNone(values.get(i - 1)) - self.assertIsNone(values.get(i - 3)) - self.assertIsNone(values.get(i - 5)) - self.assertIsNone(values.get(i - 9)) diff --git a/posthog/utils.py b/posthog/utils.py index 5a65e8ad..37f4a136 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -158,16 +158,6 @@ def __setitem__(self, key, value): super().__setitem__(key, value) - def __missing__(self, key): - if self.default_factory is None: - raise KeyError(key) - - value = self.default_factory() - # Route through __setitem__ so size limits are enforced consistently - # even when defaultdict populates missing keys. - self[key] = value - return value - class FlagCacheEntry: def __init__(self, flag_result, flag_definition_version, timestamp=None): From 29424bbd84d0ff10223cd783367e661a7578239f Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 13 Feb 2026 14:13:28 +0100 Subject: [PATCH 4/6] test(flags): make capture memory-limit test deterministic --- posthog/test/test_feature_flags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 213ba8d2..3ce89b86 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -4269,13 +4269,14 @@ def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture): disable_geoip=False, ) - @mock.patch("posthog.client.MAX_DICT_SIZE", 100) @mock.patch.object(Client, "capture") @mock.patch("posthog.client.flags") def test_capture_multiple_users_doesnt_out_of_memory( self, patch_flags, patch_capture ): client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.distinct_ids_feature_flags_reported.max_size = 100 + self.assertEqual(client.distinct_ids_feature_flags_reported.max_size, 100) client.feature_flags = [ { "id": 1, From 67f9781faca8237bb7cac10d779cfbedb4f81044 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 13 Feb 2026 14:28:00 +0100 Subject: [PATCH 5/6] fix(flags): avoid implicit defaultdict insert in capture cache --- posthog/client.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 64db985d..1600c5af 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1928,10 +1928,12 @@ def _capture_feature_flag_called( f"{key}_{'::null::' if response is None else str(response)}" ) - if ( - feature_flag_reported_key - not in self.distinct_ids_feature_flags_reported[distinct_id] - ): + reported_flags = self.distinct_ids_feature_flags_reported.get(distinct_id) + if reported_flags is None: + reported_flags = set() + self.distinct_ids_feature_flags_reported[distinct_id] = reported_flags + + if feature_flag_reported_key not in reported_flags: properties: dict[str, Any] = { "$feature_flag": key, "$feature_flag_response": response, @@ -1967,9 +1969,7 @@ def _capture_feature_flag_called( groups=groups, disable_geoip=disable_geoip, ) - self.distinct_ids_feature_flags_reported[distinct_id].add( - feature_flag_reported_key - ) + reported_flags.add(feature_flag_reported_key) def get_remote_config_payload(self, key: str): if self.disabled: From eddfb4bcc12937214af9443df5aa4c09d78a76b4 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 13 Feb 2026 14:39:39 +0100 Subject: [PATCH 6/6] test(flags): clarify deterministic cache-size setup --- posthog/test/test_feature_flags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 3ce89b86..042ddb4b 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -4275,8 +4275,9 @@ def test_capture_multiple_users_doesnt_out_of_memory( self, patch_flags, patch_capture ): client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + # Set on the instance to avoid relying on module-constant patching behavior + # across Python/runtime implementations. client.distinct_ids_feature_flags_reported.max_size = 100 - self.assertEqual(client.distinct_ids_feature_flags_reported.max_size, 100) client.feature_flags = [ { "id": 1,