Skip to content

Commit bae355c

Browse files
chore: configurabel endpoint for flags local evaluation (#483)
* chore: configurabel endpoint for flags local evaluation * lint * greptile suggestions
1 parent c3f9cfc commit bae355c

3 files changed

Lines changed: 124 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
feat(flags): make local evaluation endpoint configurable via `POSTHOG_LOCAL_EVALUATION_ENDPOINT` env var with fallback to default endpoint

posthog/client.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,19 +1284,55 @@ def _load_feature_flags(self):
12841284
if should_fetch:
12851285
self._fetch_feature_flags_from_api()
12861286

1287+
# Default (Django) endpoint for local evaluation
1288+
_DEFAULT_LOCAL_EVAL_ENDPOINT = "/api/feature_flag/local_evaluation/"
1289+
1290+
def _get_local_eval_endpoint(self):
1291+
"""Get the local evaluation endpoint URL, configurable via env var."""
1292+
return os.environ.get(
1293+
"POSTHOG_LOCAL_EVALUATION_ENDPOINT",
1294+
self._DEFAULT_LOCAL_EVAL_ENDPOINT,
1295+
)
1296+
12871297
def _fetch_feature_flags_from_api(self):
12881298
"""Fetch feature flags from the PostHog API."""
12891299
try:
12901300
# Store old flags to detect changes
12911301
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
12921302

1293-
response = get(
1294-
self.personal_api_key,
1295-
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
1296-
self.host,
1297-
timeout=10,
1298-
etag=self._flags_etag,
1299-
)
1303+
endpoint = self._get_local_eval_endpoint()
1304+
url = f"{endpoint}?token={self.api_key}&send_cohorts"
1305+
# Ensure URL has leading slash
1306+
if not url.startswith("/"):
1307+
url = f"/{url}"
1308+
1309+
try:
1310+
response = get(
1311+
self.personal_api_key,
1312+
url,
1313+
self.host,
1314+
timeout=10,
1315+
etag=self._flags_etag,
1316+
)
1317+
except Exception as e:
1318+
# Fall back to the stable Django endpoint when the custom endpoint
1319+
# (e.g. the Rust-backed /flags/definitions) fails. This enables a
1320+
# zero-downtime gradual migration: the custom endpoint is tried first
1321+
# and, on any error, flag evaluation degrades transparently to the
1322+
# default rather than being blocked entirely.
1323+
if endpoint != self._DEFAULT_LOCAL_EVAL_ENDPOINT:
1324+
self.log.warning(
1325+
f"[FEATURE FLAGS] Custom endpoint {endpoint} failed ({e}), falling back to {self._DEFAULT_LOCAL_EVAL_ENDPOINT}"
1326+
)
1327+
response = get(
1328+
self.personal_api_key,
1329+
f"{self._DEFAULT_LOCAL_EVAL_ENDPOINT}?token={self.api_key}&send_cohorts",
1330+
self.host,
1331+
timeout=10,
1332+
etag=self._flags_etag,
1333+
)
1334+
else:
1335+
raise
13001336

13011337
# Update stored ETag (clear if server stops sending one)
13021338
self._flags_etag = response.etag

posthog/test/test_feature_flags.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import datetime
2+
import os
23
import unittest
34

45
import mock
56
from dateutil import parser, tz
67
from freezegun import freeze_time
8+
from parameterized import parameterized
79

810
from posthog.client import Client
911
from posthog.feature_flags import (
@@ -3780,6 +3782,80 @@ def test_get_all_flags_fallback_when_device_id_missing_for_some_flags(
37803782
self.assertEqual(patch_flags.call_count, 1)
37813783

37823784

3785+
class TestLocalEvalEndpointConfig(unittest.TestCase):
3786+
@classmethod
3787+
def setUpClass(cls):
3788+
cls.capture_patch = mock.patch.object(Client, "capture")
3789+
cls.capture_patch.start()
3790+
3791+
@classmethod
3792+
def tearDownClass(cls):
3793+
cls.capture_patch.stop()
3794+
3795+
@parameterized.expand(
3796+
[
3797+
("custom_endpoint", "/flags/definitions", "/flags/definitions?"),
3798+
("default_endpoint", None, "/api/feature_flag/local_evaluation/"),
3799+
]
3800+
)
3801+
@mock.patch("posthog.client.get")
3802+
def test_endpoint_selection(self, _name, env_value, expected_prefix, patch_get):
3803+
patch_get.return_value = GetResponse(
3804+
data={"flags": [], "group_type_mapping": {}},
3805+
etag=None,
3806+
not_modified=False,
3807+
)
3808+
env = {"POSTHOG_LOCAL_EVALUATION_ENDPOINT": env_value} if env_value else {}
3809+
with mock.patch.dict("os.environ", env, clear=False):
3810+
if env_value is None:
3811+
os.environ.pop("POSTHOG_LOCAL_EVALUATION_ENDPOINT", None)
3812+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test-key")
3813+
client._fetch_feature_flags_from_api()
3814+
call_url = patch_get.call_args[0][1]
3815+
self.assertTrue(
3816+
call_url.startswith(expected_prefix),
3817+
f"Expected URL starting with {expected_prefix}, got: {call_url}",
3818+
)
3819+
3820+
@parameterized.expand(
3821+
[
3822+
("custom_endpoint_falls_back", "/flags/definitions", 2),
3823+
("default_endpoint_no_fallback", None, 1),
3824+
]
3825+
)
3826+
@mock.patch("posthog.client.get")
3827+
def test_endpoint_fallback_on_failure(
3828+
self, _name, env_value, expected_call_count, patch_get
3829+
):
3830+
success_response = GetResponse(
3831+
data={"flags": [], "group_type_mapping": {}},
3832+
etag=None,
3833+
not_modified=False,
3834+
)
3835+
if expected_call_count == 2:
3836+
patch_get.side_effect = [Exception("connection refused"), success_response]
3837+
else:
3838+
patch_get.side_effect = Exception("connection refused")
3839+
3840+
env = {"POSTHOG_LOCAL_EVALUATION_ENDPOINT": env_value} if env_value else {}
3841+
with mock.patch.dict("os.environ", env, clear=False):
3842+
if env_value is None:
3843+
os.environ.pop("POSTHOG_LOCAL_EVALUATION_ENDPOINT", None)
3844+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test-key")
3845+
client._fetch_feature_flags_from_api()
3846+
self.assertEqual(patch_get.call_count, expected_call_count)
3847+
if expected_call_count == 2:
3848+
# First call used custom endpoint, second fell back to default
3849+
self.assertTrue(
3850+
patch_get.call_args_list[0][0][1].startswith("/flags/definitions?")
3851+
)
3852+
self.assertTrue(
3853+
patch_get.call_args_list[1][0][1].startswith(
3854+
"/api/feature_flag/local_evaluation/"
3855+
)
3856+
)
3857+
3858+
37833859
class TestMatchProperties(unittest.TestCase):
37843860
def property(self, key, value, operator=None):
37853861
result = {"key": key, "value": value}

0 commit comments

Comments
 (0)