Skip to content

feat(feature-flags): support quota limiting for feature flags #195

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Feb 21, 2025
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 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>
Expand Down
13 changes: 13 additions & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,19 @@ def _load_feature_flags(self):
"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 = {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should we stay consistent with the 401 case above and raise an APIError if debug mode is on?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah good call


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:
Expand Down
18 changes: 17 additions & 1 deletion posthog/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'm assuming this is to cover the /decide case, correct? Can / should we avoid the special condition for feature_flags and instead log any quota limited services, maybe raising an error for any of them (or, keep a list of services that should throw an error if quota limited)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah, good call. I think for now I explicitly want to limit feature flag events for now (since I don't know how other services are handling quota limits in the SDK).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll make a comment to that end though.

):
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)
Expand Down Expand Up @@ -112,6 +124,10 @@ def __str__(self):
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)):
Expand Down
20 changes: 20 additions & 0 deletions posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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

Expand Down Expand Up @@ -384,6 +385,25 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_decide):
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"}}
Expand Down
33 changes: 32 additions & 1 deletion posthog/test/test_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
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


Expand Down Expand Up @@ -44,6 +45,36 @@ def test_should_timeout(self):
"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",
Expand Down
2 changes: 1 addition & 1 deletion posthog/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION = "3.14.2"
VERSION = "3.15.0"

if __name__ == "__main__":
print(VERSION, end="") # noqa: T201