Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0f9e35c1 | ||
|
|
337f7da7c5 |
@@ -1,3 +1,10 @@
|
||||
## 3.15.0 - 2025-02-19
|
||||
|
||||
1. Support quota-limited feature flags
|
||||
|
||||
## 3.14.2 - 2025-02-19
|
||||
|
||||
1. Evaluate feature flag payloads with case sensitivity correctly. Fixes <https://github.com/PostHog/posthog-python/issues/178>
|
||||
|
||||
## 3.14.1 - 2025-02-18
|
||||
|
||||
|
||||
+22
-5
@@ -600,6 +600,19 @@ class Client(object):
|
||||
"To use feature flags, please set a personal_api_key "
|
||||
"More information: https://posthog.com/docs/api/overview",
|
||||
)
|
||||
elif e.status == 402:
|
||||
self.log.warning("[FEATURE FLAGS] PostHog feature flags quota limited")
|
||||
# Reset all feature flag data when quota limited
|
||||
self.feature_flags = []
|
||||
self.feature_flags_by_key = {}
|
||||
self.group_type_mapping = {}
|
||||
self.cohorts = {}
|
||||
|
||||
if self.debug:
|
||||
raise APIError(
|
||||
status=402,
|
||||
message="PostHog feature flags quota limited",
|
||||
)
|
||||
else:
|
||||
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
|
||||
except Exception as e:
|
||||
@@ -822,7 +835,7 @@ class Client(object):
|
||||
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
)
|
||||
response = responses_and_payloads["featureFlags"].get(key, None)
|
||||
payload = responses_and_payloads["featureFlagPayloads"].get(str(key).lower(), None)
|
||||
payload = responses_and_payloads["featureFlagPayloads"].get(str(key), None)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
|
||||
|
||||
@@ -875,10 +888,14 @@ class Client(object):
|
||||
if self.feature_flags_by_key is None:
|
||||
return payload
|
||||
|
||||
flag_definition = self.feature_flags_by_key.get(key) or {}
|
||||
flag_filters = flag_definition.get("filters") or {}
|
||||
flag_payloads = flag_filters.get("payloads") or {}
|
||||
payload = flag_payloads.get(str(match_value).lower(), None)
|
||||
flag_definition = self.feature_flags_by_key.get(key)
|
||||
if flag_definition:
|
||||
flag_filters = flag_definition.get("filters") or {}
|
||||
flag_payloads = flag_filters.get("payloads") or {}
|
||||
# For boolean flags, convert True to "true"
|
||||
# For multivariate flags, use the variant string as-is
|
||||
lookup_value = "true" if isinstance(match_value, bool) and match_value else str(match_value)
|
||||
payload = flag_payloads.get(lookup_value, None)
|
||||
return payload
|
||||
|
||||
def get_all_flags(
|
||||
|
||||
+17
-1
@@ -68,7 +68,19 @@ def _process_response(
|
||||
log = logging.getLogger("posthog")
|
||||
if res.status_code == 200:
|
||||
log.debug(success_message)
|
||||
return res.json() if return_json else res
|
||||
response = res.json() if return_json else res
|
||||
# Handle quota limited decide responses by raising a specific error
|
||||
# NB: other services also put entries into the quotaLimited key, but right now we only care about feature flags
|
||||
# since most of the other services handle quota limiting in other places in the application.
|
||||
if (
|
||||
isinstance(response, dict)
|
||||
and "quotaLimited" in response
|
||||
and isinstance(response["quotaLimited"], list)
|
||||
and "feature_flags" in response["quotaLimited"]
|
||||
):
|
||||
log.warning("PostHog feature flags quota limited")
|
||||
raise QuotaLimitError(res.status_code, "Feature flags quota limited")
|
||||
return response
|
||||
try:
|
||||
payload = res.json()
|
||||
log.debug("received response: %s", payload)
|
||||
@@ -112,6 +124,10 @@ class APIError(Exception):
|
||||
return msg.format(self.message, self.status)
|
||||
|
||||
|
||||
class QuotaLimitError(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class DatetimeSerializer(json.JSONEncoder):
|
||||
def default(self, obj: Any):
|
||||
if isinstance(obj, (date, datetime)):
|
||||
|
||||
@@ -7,6 +7,7 @@ import mock
|
||||
import six
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.request import APIError
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.version import VERSION
|
||||
|
||||
@@ -384,6 +385,25 @@ class TestClient(unittest.TestCase):
|
||||
assert "$feature/false-flag" not in msg["properties"]
|
||||
assert "$active_feature_flags" not in msg["properties"]
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags_quota_limited(self, patch_get):
|
||||
mock_response = {
|
||||
"type": "quota_limited",
|
||||
"detail": "You have exceeded your feature flag request quota",
|
||||
"code": "payment_required",
|
||||
}
|
||||
patch_get.side_effect = APIError(402, mock_response["detail"])
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
with self.assertLogs("posthog", level="WARNING") as logs:
|
||||
client._load_feature_flags()
|
||||
|
||||
self.assertEqual(client.feature_flags, [])
|
||||
self.assertEqual(client.feature_flags_by_key, {})
|
||||
self.assertEqual(client.group_type_mapping, {})
|
||||
self.assertEqual(client.cohorts, {})
|
||||
self.assertIn("PostHog feature flags quota limited", logs.output[0])
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_dont_override_capture_with_local_flags(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
|
||||
@@ -4640,3 +4640,84 @@ class TestConsistency(unittest.TestCase):
|
||||
self.assertEqual(feature_flag_match, results[i])
|
||||
else:
|
||||
self.assertFalse(feature_flag_match)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_flag_case_sensitive(self, mock_decide):
|
||||
mock_decide.return_value = {"featureFlags": {}} # Ensure decide returns empty flags
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that flag evaluation is case-sensitive
|
||||
self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
|
||||
self.assertFalse(client.feature_enabled("beta-feature", "user1"))
|
||||
self.assertFalse(client.feature_enabled("BETA-FEATURE", "user1"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_flag_payload_case_sensitive(self, mock_decide):
|
||||
mock_decide.return_value = {
|
||||
"featureFlags": {"Beta-Feature": True},
|
||||
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
|
||||
}
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
"payloads": {
|
||||
"true": {"some": "value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that payload retrieval is case-sensitive
|
||||
self.assertEqual(client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"})
|
||||
self.assertIsNone(client.get_feature_flag_payload("beta-feature", "user1"))
|
||||
self.assertIsNone(client.get_feature_flag_payload("BETA-FEATURE", "user1"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_flag_case_sensitive_consistency(self, mock_decide):
|
||||
mock_decide.return_value = {
|
||||
"featureFlags": {"Beta-Feature": True},
|
||||
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
|
||||
}
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
"payloads": {
|
||||
"true": {"some": "value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that flag evaluation and payload retrieval are consistently case-sensitive
|
||||
# Only exact match should work
|
||||
self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
|
||||
self.assertEqual(client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"})
|
||||
|
||||
# Different cases should not match
|
||||
test_cases = ["beta-feature", "BETA-FEATURE", "bEtA-FeAtUrE"]
|
||||
for case in test_cases:
|
||||
self.assertFalse(client.feature_enabled(case, "user1"))
|
||||
self.assertIsNone(client.get_feature_flag_payload(case, "user1"))
|
||||
|
||||
@@ -2,10 +2,11 @@ import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from posthog.request import DatetimeSerializer, batch_post, determine_server_host
|
||||
from posthog.request import DatetimeSerializer, QuotaLimitError, batch_post, decide, determine_server_host
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
@@ -44,6 +45,36 @@ class TestRequests(unittest.TestCase):
|
||||
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
|
||||
)
|
||||
|
||||
def test_quota_limited_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"quotaLimited": ["feature_flags"],
|
||||
"featureFlags": {},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
with self.assertRaises(QuotaLimitError) as cm:
|
||||
decide("fake_key", "fake_host")
|
||||
|
||||
self.assertEqual(cm.exception.status, 200)
|
||||
self.assertEqual(cm.exception.message, "Feature flags quota limited")
|
||||
|
||||
def test_normal_decide_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{"featureFlags": {"flag1": True}, "featureFlagPayloads": {}, "errorsWhileComputingFlags": False}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
response = decide("fake_key", "fake_host")
|
||||
self.assertEqual(response["featureFlags"], {"flag1": True})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host, expected",
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "3.14.1"
|
||||
VERSION = "3.15.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
Reference in New Issue
Block a user