Skip to content

Commit ef99809

Browse files
committed
Add ETag support for local evaluation polling
Add support for HTTP conditional requests using ETags to reduce bandwidth when polling for feature flag definitions. When flag definitions haven't changed, the server returns 304 Not Modified and the SDK skips processing. - Add GetResponse dataclass to encapsulate response data, ETag, and status - Update get() to send If-None-Match header and handle 304 responses - Store ETag in client and pass it on subsequent polling requests - Skip flag processing when 304 Not Modified is received
1 parent 2855977 commit ef99809

File tree

4 files changed

+216
-45
lines changed

4 files changed

+216
-45
lines changed

posthog/client.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def __init__(
232232
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
233233
self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
234234
self.flag_definition_version = 0
235+
self._flags_etag: Optional[str] = None
235236
self.disabled = disabled
236237
self.disable_geoip = disable_geoip
237238
self.historical_migration = historical_migration
@@ -1183,11 +1184,24 @@ def _load_feature_flags(self):
11831184
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
11841185
self.host,
11851186
timeout=10,
1187+
etag=self._flags_etag,
11861188
)
11871189

1188-
self.feature_flags = response["flags"] or []
1189-
self.group_type_mapping = response["group_type_mapping"] or {}
1190-
self.cohorts = response["cohorts"] or {}
1190+
# Update stored ETag
1191+
if response.etag:
1192+
self._flags_etag = response.etag
1193+
1194+
# If 304 Not Modified, flags haven't changed - skip processing
1195+
if response.not_modified:
1196+
self.log.debug(
1197+
"[FEATURE FLAGS] Flags not modified (304), using cached data"
1198+
)
1199+
self._last_feature_flag_poll = datetime.now(tz=tzutc())
1200+
return
1201+
1202+
self.feature_flags = response.data["flags"] or []
1203+
self.group_type_mapping = response.data["group_type_mapping"] or {}
1204+
self.cohorts = response.data["cohorts"] or {}
11911205

11921206
# Check if flag definitions changed and update version
11931207
if self.flag_cache and old_flags_by_key != (

posthog/request.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
from dataclasses import dataclass
34
from datetime import date, datetime
45
from gzip import GzipFile
56
from io import BytesIO
@@ -12,6 +13,16 @@
1213
from posthog.utils import remove_trailing_slash
1314
from posthog.version import VERSION
1415

16+
17+
@dataclass
18+
class GetResponse:
19+
"""Response from a GET request with ETag support."""
20+
21+
data: Any
22+
etag: Optional[str] = None
23+
not_modified: bool = False
24+
25+
1526
# Retry on both connect and read errors
1627
# by default read errors will only retry idempotent HTTP methods (so not POST)
1728
adapter = requests.adapters.HTTPAdapter(
@@ -139,12 +150,13 @@ def remote_config(
139150
timeout: int = 15,
140151
) -> Any:
141152
"""Get remote config flag value from remote_config API endpoint"""
142-
return get(
153+
response = get(
143154
personal_api_key,
144155
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
145156
host,
146157
timeout,
147158
)
159+
return response.data
148160

149161

150162
def batch_post(
@@ -162,15 +174,40 @@ def batch_post(
162174

163175

164176
def get(
165-
api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None
166-
) -> requests.Response:
167-
url = remove_trailing_slash(host or DEFAULT_HOST) + url
168-
res = requests.get(
169-
url,
170-
headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT},
171-
timeout=timeout,
177+
api_key: str,
178+
url: str,
179+
host: Optional[str] = None,
180+
timeout: Optional[int] = None,
181+
etag: Optional[str] = None,
182+
) -> GetResponse:
183+
"""
184+
Make a GET request with optional ETag support.
185+
186+
If an etag is provided, sends If-None-Match header. Returns GetResponse with:
187+
- not_modified=True and data=None if server returns 304
188+
- not_modified=False and data=response if server returns 200
189+
"""
190+
log = logging.getLogger("posthog")
191+
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
192+
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
193+
194+
if etag:
195+
headers["If-None-Match"] = etag
196+
197+
res = requests.get(full_url, headers=headers, timeout=timeout)
198+
199+
# Handle 304 Not Modified
200+
if res.status_code == 304:
201+
log.debug(f"GET {full_url} returned 304 Not Modified")
202+
response_etag = res.headers.get("ETag")
203+
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
204+
205+
# Handle normal response
206+
data = _process_response(
207+
res, success_message=f"GET {full_url} completed successfully"
172208
)
173-
return _process_response(res, success_message=f"GET {url} completed successfully")
209+
response_etag = res.headers.get("ETag")
210+
return GetResponse(data=data, etag=response_etag, not_modified=False)
174211

175212

176213
class APIError(Exception):

posthog/test/test_client.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from posthog.client import Client
1111
from posthog.contexts import get_context_session_id, new_context, set_context_session
12-
from posthog.request import APIError
12+
from posthog.request import APIError, GetResponse
1313
from posthog.test.test_utils import FAKE_TEST_API_KEY
1414
from posthog.types import FeatureFlag, LegacyFlagMetadata
1515
from posthog.version import VERSION
@@ -2095,13 +2095,21 @@ def test_enable_local_evaluation_false_disables_poller(
20952095
self, patch_get, patch_poller
20962096
):
20972097
"""Test that when enable_local_evaluation=False, the poller is not started"""
2098-
patch_get.return_value = {
2099-
"flags": [
2100-
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
2101-
],
2102-
"group_type_mapping": {},
2103-
"cohorts": {},
2104-
}
2098+
patch_get.return_value = GetResponse(
2099+
data={
2100+
"flags": [
2101+
{
2102+
"id": 1,
2103+
"name": "Beta Feature",
2104+
"key": "beta-feature",
2105+
"active": True,
2106+
}
2107+
],
2108+
"group_type_mapping": {},
2109+
"cohorts": {},
2110+
},
2111+
etag='"test-etag"',
2112+
)
21052113

21062114
client = Client(
21072115
FAKE_TEST_API_KEY,
@@ -2123,13 +2131,21 @@ def test_enable_local_evaluation_false_disables_poller(
21232131
@mock.patch("posthog.client.get")
21242132
def test_enable_local_evaluation_true_starts_poller(self, patch_get, patch_poller):
21252133
"""Test that when enable_local_evaluation=True (default), the poller is started"""
2126-
patch_get.return_value = {
2127-
"flags": [
2128-
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
2129-
],
2130-
"group_type_mapping": {},
2131-
"cohorts": {},
2132-
}
2134+
patch_get.return_value = GetResponse(
2135+
data={
2136+
"flags": [
2137+
{
2138+
"id": 1,
2139+
"name": "Beta Feature",
2140+
"key": "beta-feature",
2141+
"active": True,
2142+
}
2143+
],
2144+
"group_type_mapping": {},
2145+
"cohorts": {},
2146+
},
2147+
etag='"test-etag"',
2148+
)
21332149

21342150
client = Client(
21352151
FAKE_TEST_API_KEY,

posthog/test/test_feature_flags.py

Lines changed: 122 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
match_property,
1212
relative_date_parse_for_feature_flag_matching,
1313
)
14-
from posthog.request import APIError
14+
from posthog.request import APIError, GetResponse
1515
from posthog.test.test_utils import FAKE_TEST_API_KEY
1616

1717

@@ -2348,23 +2348,27 @@ def test_production_style_multivariate_dependency_chain(
23482348
@mock.patch("posthog.client.Poller")
23492349
@mock.patch("posthog.client.get")
23502350
def test_load_feature_flags(self, patch_get, patch_poll):
2351-
patch_get.return_value = {
2352-
"flags": [
2353-
{
2354-
"id": 1,
2355-
"name": "Beta Feature",
2356-
"key": "beta-feature",
2357-
"active": True,
2358-
},
2359-
{
2360-
"id": 2,
2361-
"name": "Alpha Feature",
2362-
"key": "alpha-feature",
2363-
"active": False,
2364-
},
2365-
],
2366-
"group_type_mapping": {"0": "company"},
2367-
}
2351+
patch_get.return_value = GetResponse(
2352+
data={
2353+
"flags": [
2354+
{
2355+
"id": 1,
2356+
"name": "Beta Feature",
2357+
"key": "beta-feature",
2358+
"active": True,
2359+
},
2360+
{
2361+
"id": 2,
2362+
"name": "Alpha Feature",
2363+
"key": "alpha-feature",
2364+
"active": False,
2365+
},
2366+
],
2367+
"group_type_mapping": {"0": "company"},
2368+
"cohorts": {},
2369+
},
2370+
etag='"abc123"',
2371+
)
23682372
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
23692373
with freeze_time("2020-01-01T12:01:00.0000Z"):
23702374
client.load_feature_flags()
@@ -2375,6 +2379,106 @@ def test_load_feature_flags(self, patch_get, patch_poll):
23752379
client._last_feature_flag_poll.isoformat(), "2020-01-01T12:01:00+00:00"
23762380
)
23772381
self.assertEqual(patch_poll.call_count, 1)
2382+
# Verify ETag is stored
2383+
self.assertEqual(client._flags_etag, '"abc123"')
2384+
2385+
@mock.patch("posthog.client.Poller")
2386+
@mock.patch("posthog.client.get")
2387+
def test_load_feature_flags_sends_etag_on_subsequent_requests(
2388+
self, patch_get, patch_poll
2389+
):
2390+
"""Test that the ETag is sent in If-None-Match header on subsequent requests"""
2391+
patch_get.return_value = GetResponse(
2392+
data={
2393+
"flags": [{"id": 1, "key": "beta-feature", "active": True}],
2394+
"group_type_mapping": {},
2395+
"cohorts": {},
2396+
},
2397+
etag='"initial-etag"',
2398+
)
2399+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
2400+
client.load_feature_flags()
2401+
2402+
# First call should have no etag
2403+
first_call_kwargs = patch_get.call_args_list[0][1]
2404+
self.assertIsNone(first_call_kwargs.get("etag"))
2405+
2406+
# Simulate second call
2407+
client._load_feature_flags()
2408+
2409+
# Second call should have the etag
2410+
second_call_kwargs = patch_get.call_args_list[1][1]
2411+
self.assertEqual(second_call_kwargs.get("etag"), '"initial-etag"')
2412+
2413+
@mock.patch("posthog.client.Poller")
2414+
@mock.patch("posthog.client.get")
2415+
def test_load_feature_flags_304_not_modified(self, patch_get, patch_poll):
2416+
"""Test that 304 Not Modified responses skip flag processing"""
2417+
# First response with flags
2418+
initial_response = GetResponse(
2419+
data={
2420+
"flags": [{"id": 1, "key": "beta-feature", "active": True}],
2421+
"group_type_mapping": {"0": "company"},
2422+
"cohorts": {},
2423+
},
2424+
etag='"test-etag"',
2425+
)
2426+
# Second response is 304 Not Modified
2427+
not_modified_response = GetResponse(
2428+
data=None,
2429+
etag='"test-etag"',
2430+
not_modified=True,
2431+
)
2432+
patch_get.side_effect = [initial_response, not_modified_response]
2433+
2434+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
2435+
client.load_feature_flags()
2436+
2437+
# Verify initial flags are loaded
2438+
self.assertEqual(len(client.feature_flags), 1)
2439+
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
2440+
self.assertEqual(client.group_type_mapping, {"0": "company"})
2441+
2442+
# Second call with 304
2443+
client._load_feature_flags()
2444+
2445+
# Flags should still be the same (not cleared)
2446+
self.assertEqual(len(client.feature_flags), 1)
2447+
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
2448+
self.assertEqual(client.group_type_mapping, {"0": "company"})
2449+
2450+
@mock.patch("posthog.client.Poller")
2451+
@mock.patch("posthog.client.get")
2452+
def test_load_feature_flags_etag_updated_on_new_response(
2453+
self, patch_get, patch_poll
2454+
):
2455+
"""Test that ETag is updated when flags change"""
2456+
patch_get.side_effect = [
2457+
GetResponse(
2458+
data={
2459+
"flags": [{"id": 1, "key": "flag-v1", "active": True}],
2460+
"group_type_mapping": {},
2461+
"cohorts": {},
2462+
},
2463+
etag='"etag-v1"',
2464+
),
2465+
GetResponse(
2466+
data={
2467+
"flags": [{"id": 1, "key": "flag-v2", "active": True}],
2468+
"group_type_mapping": {},
2469+
"cohorts": {},
2470+
},
2471+
etag='"etag-v2"',
2472+
),
2473+
]
2474+
2475+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
2476+
client.load_feature_flags()
2477+
self.assertEqual(client._flags_etag, '"etag-v1"')
2478+
2479+
client._load_feature_flags()
2480+
self.assertEqual(client._flags_etag, '"etag-v2"')
2481+
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
23782482

23792483
def test_load_feature_flags_wrong_key(self):
23802484
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

0 commit comments

Comments
 (0)