Skip to content

Commit fff9992

Browse files
authored
feat(flags): Add ETag support for local evaluation polling (#381)
* 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 * Use _session rather than requests Benefits: 1. Reuses TCP connections via keep-alive 2. 2 retries on connect/read errors 3. Faster handshakes * Add unit tests for get() function Test HTTP-level behavior including: - ETag extraction from response headers - If-None-Match header sent when etag provided - 304 Not Modified response handling - Fallback when 304 has no ETag header - Error response handling (APIError) - Authorization and User-Agent headers - Timeout and URL construction * Add defensive null check for response.data Guard against unexpected None data in non-304 responses to prevent TypeError when accessing dictionary keys. * Clear stored ETag when server stops sending one If the server stops including ETag headers in responses, clear the stored ETag so we don't keep sending a stale If-None-Match header. * Mask API tokens in log messages Keep first 10 chars visible for identification while hiding the rest. Addresses CodeQL security warning about logging sensitive data. * Ran ruff format
1 parent c253e41 commit fff9992

File tree

5 files changed

+478
-45
lines changed

5 files changed

+478
-45
lines changed

posthog/client.py

Lines changed: 22 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,29 @@ 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 (clear if server stops sending one)
1191+
self._flags_etag = response.etag
1192+
1193+
# If 304 Not Modified, flags haven't changed - skip processing
1194+
if response.not_modified:
1195+
self.log.debug(
1196+
"[FEATURE FLAGS] Flags not modified (304), using cached data"
1197+
)
1198+
self._last_feature_flag_poll = datetime.now(tz=tzutc())
1199+
return
1200+
1201+
if response.data is None:
1202+
self.log.error(
1203+
"[FEATURE FLAGS] Unexpected empty response data in non-304 response"
1204+
)
1205+
return
1206+
1207+
self.feature_flags = response.data["flags"] or []
1208+
self.group_type_mapping = response.data["group_type_mapping"] or {}
1209+
self.cohorts = response.data["cohorts"] or {}
11911210

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

posthog/request.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
22
import logging
3+
import re
4+
from dataclasses import dataclass
35
from datetime import date, datetime
46
from gzip import GzipFile
57
from io import BytesIO
@@ -12,6 +14,21 @@
1214
from posthog.utils import remove_trailing_slash
1315
from posthog.version import VERSION
1416

17+
18+
def _mask_tokens_in_url(url: str) -> str:
19+
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
20+
return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url)
21+
22+
23+
@dataclass
24+
class GetResponse:
25+
"""Response from a GET request with ETag support."""
26+
27+
data: Any
28+
etag: Optional[str] = None
29+
not_modified: bool = False
30+
31+
1532
# Retry on both connect and read errors
1633
# by default read errors will only retry idempotent HTTP methods (so not POST)
1734
adapter = requests.adapters.HTTPAdapter(
@@ -139,12 +156,13 @@ def remote_config(
139156
timeout: int = 15,
140157
) -> Any:
141158
"""Get remote config flag value from remote_config API endpoint"""
142-
return get(
159+
response = get(
143160
personal_api_key,
144161
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
145162
host,
146163
timeout,
147164
)
165+
return response.data
148166

149167

150168
def batch_post(
@@ -162,15 +180,42 @@ def batch_post(
162180

163181

164182
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,
183+
api_key: str,
184+
url: str,
185+
host: Optional[str] = None,
186+
timeout: Optional[int] = None,
187+
etag: Optional[str] = None,
188+
) -> GetResponse:
189+
"""
190+
Make a GET request with optional ETag support.
191+
192+
If an etag is provided, sends If-None-Match header. Returns GetResponse with:
193+
- not_modified=True and data=None if server returns 304
194+
- not_modified=False and data=response if server returns 200
195+
"""
196+
log = logging.getLogger("posthog")
197+
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
198+
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
199+
200+
if etag:
201+
headers["If-None-Match"] = etag
202+
203+
res = _session.get(full_url, headers=headers, timeout=timeout)
204+
205+
masked_url = _mask_tokens_in_url(full_url)
206+
207+
# Handle 304 Not Modified
208+
if res.status_code == 304:
209+
log.debug(f"GET {masked_url} returned 304 Not Modified")
210+
response_etag = res.headers.get("ETag")
211+
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
212+
213+
# Handle normal response
214+
data = _process_response(
215+
res, success_message=f"GET {masked_url} completed successfully"
172216
)
173-
return _process_response(res, success_message=f"GET {url} completed successfully")
217+
response_etag = res.headers.get("ETag")
218+
return GetResponse(data=data, etag=response_etag, not_modified=False)
174219

175220

176221
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: 155 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,139 @@ 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")
2482+
2483+
@mock.patch("posthog.client.Poller")
2484+
@mock.patch("posthog.client.get")
2485+
def test_load_feature_flags_clears_etag_when_server_stops_sending(
2486+
self, patch_get, patch_poll
2487+
):
2488+
"""Test that ETag is cleared when server stops sending it"""
2489+
patch_get.side_effect = [
2490+
GetResponse(
2491+
data={
2492+
"flags": [{"id": 1, "key": "flag-v1", "active": True}],
2493+
"group_type_mapping": {},
2494+
"cohorts": {},
2495+
},
2496+
etag='"etag-v1"',
2497+
),
2498+
GetResponse(
2499+
data={
2500+
"flags": [{"id": 1, "key": "flag-v2", "active": True}],
2501+
"group_type_mapping": {},
2502+
"cohorts": {},
2503+
},
2504+
etag=None, # Server stopped sending ETag
2505+
),
2506+
]
2507+
2508+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
2509+
client.load_feature_flags()
2510+
self.assertEqual(client._flags_etag, '"etag-v1"')
2511+
2512+
client._load_feature_flags()
2513+
self.assertIsNone(client._flags_etag)
2514+
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
23782515

23792516
def test_load_feature_flags_wrong_key(self):
23802517
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

0 commit comments

Comments
 (0)