Skip to content

Commit 6057139

Browse files
committed
perf: Skip env-doc re-parse when document hasn't changed
``update_environment`` now sends a HEAD first and compares the ``x-flagsmith-document-updated-at`` response header against the value stored from the last successful fetch. When they match, the GET, the JSON parse, ``map_environment_document_to_context``, and the overrides-index rebuild are all skipped — the cached evaluation context is reused as-is. On the customer's QA env this eliminates the ~5ms p99 GIL stall the polling thread imposes every ``environment_refresh_interval_seconds`` (default 60s) — which is the largest remaining contributor to identity-flag-eval p99 once lazy is enabled. Standard 60s polling against a stable env now does HEAD-only round trips between actual changes. HEAD failures (e.g. proxy that doesn't permit it) silently fall through to the existing GET path, so no environment regresses to a worse-than-current behaviour if the optimisation can't apply. beep boop
1 parent 51d9134 commit 6057139

2 files changed

Lines changed: 116 additions & 13 deletions

File tree

flagsmith/flagsmith.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ def __init__(
133133
self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None
134134
self._segment_overrides_index: SegmentOverridesIndex = {}
135135
self._environment_updated_at: typing.Optional[datetime] = None
136+
# Tracks the value of the ``x-flagsmith-document-updated-at`` header
137+
# from the last successful environment fetch, so we can short-circuit
138+
# an unchanged-document refresh without re-parsing the body.
139+
self._environment_document_updated_at_header: typing.Optional[str] = None
136140

137141
# argument validation
138142
if offline_mode and not offline_handler:
@@ -351,24 +355,60 @@ def track_event(
351355
)
352356

353357
def update_environment(self) -> None:
358+
# Cheap unchanged-document check via the API's HEAD response.
359+
# The env doc is hundreds of KB and Python-parsing+mapping it
360+
# holds the GIL for several ms — costs we'd rather not pay on
361+
# every refresh tick when the document is identical to last time.
362+
# HEAD is an optimisation only: if it fails for any reason
363+
# (server, proxy, network) we just fall through to the canonical
364+
# GET path and the standard error handling kicks in there.
354365
try:
355-
environment_data = self._get_json_response(
356-
self.environment_url, method="GET"
366+
head = self.session.head(
367+
self.environment_url,
368+
timeout=self.request_timeout_seconds,
357369
)
358-
except FlagsmithAPIError:
370+
head.raise_for_status()
371+
updated_at = head.headers.get("x-flagsmith-document-updated-at")
372+
if (
373+
updated_at is not None
374+
and updated_at == self._environment_document_updated_at_header
375+
):
376+
# Document is identical to the last fetch — skip the GET,
377+
# the parse, and the index rebuild entirely.
378+
return
379+
except requests.RequestException:
380+
pass
381+
382+
try:
383+
response = self.session.get(
384+
self.environment_url,
385+
timeout=self.request_timeout_seconds,
386+
)
387+
response.raise_for_status()
388+
environment_data = response.json()
389+
except (requests.RequestException, ValueError):
359390
logger.exception("Error retrieving environment document from API")
360-
else:
361-
try:
362-
self._evaluation_context = map_environment_document_to_context(
391+
return
392+
393+
try:
394+
self._evaluation_context = map_environment_document_to_context(
395+
environment_data,
396+
)
397+
self._environment_updated_at = (
398+
map_environment_document_to_environment_updated_at(
363399
environment_data,
364400
)
365-
self._environment_updated_at = (
366-
map_environment_document_to_environment_updated_at(
367-
environment_data,
368-
)
369-
)
370-
except (KeyError, TypeError, ValueError):
371-
logger.exception("Error parsing environment document")
401+
)
402+
except (KeyError, TypeError, ValueError):
403+
logger.exception("Error parsing environment document")
404+
return
405+
406+
# Only record the freshness marker once we've successfully
407+
# applied the new document — partial failures shouldn't suppress
408+
# the next refresh.
409+
self._environment_document_updated_at_header = response.headers.get(
410+
"x-flagsmith-document-updated-at",
411+
)
372412

373413
@property
374414
def _evaluation_context(self) -> typing.Optional[SDKEvaluationContext]:

tests/test_flagsmith.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,69 @@ def test_update_environment_sets_environment(
5656
assert flagsmith._evaluation_context == evaluation_context
5757

5858

59+
@responses.activate()
60+
def test_update_environment__unchanged_document__skips_parse(
61+
flagsmith: Flagsmith,
62+
environment_json: str,
63+
) -> None:
64+
# Given: every request (HEAD or GET) returns the same updated-at
65+
# header so the second refresh sees an unchanged document.
66+
fixed_header = {"x-flagsmith-document-updated-at": "1777306230.090367"}
67+
responses.add(
68+
method="GET",
69+
url=flagsmith.environment_url,
70+
body=environment_json,
71+
headers=fixed_header,
72+
)
73+
responses.add(
74+
method="HEAD",
75+
url=flagsmith.environment_url,
76+
headers=fixed_header,
77+
)
78+
79+
# When: first refresh populates the context.
80+
flagsmith.update_environment()
81+
first_context_id = id(flagsmith._evaluation_context)
82+
assert (
83+
flagsmith._environment_document_updated_at_header
84+
== fixed_header["x-flagsmith-document-updated-at"]
85+
)
86+
87+
# And: second refresh sees a HEAD with the same updated-at header and
88+
# short-circuits before doing the body GET / parse / index rebuild.
89+
flagsmith.update_environment()
90+
91+
# Then: only one body GET was made (the rest are HEADs), and the
92+
# context object is the same instance — never re-parsed.
93+
get_calls = [c for c in responses.calls if c.request.method == "GET"]
94+
head_calls = [c for c in responses.calls if c.request.method == "HEAD"]
95+
assert len(get_calls) == 1
96+
assert len(head_calls) >= 1
97+
assert id(flagsmith._evaluation_context) == first_context_id
98+
99+
100+
@responses.activate()
101+
def test_update_environment__head_failure__falls_through_to_get(
102+
flagsmith: Flagsmith,
103+
environment_json: str,
104+
) -> None:
105+
# Given: HEAD fails (e.g. proxy doesn't allow it) but GET works.
106+
responses.add(method="HEAD", url=flagsmith.environment_url, status=405)
107+
responses.add(
108+
method="GET",
109+
url=flagsmith.environment_url,
110+
body=environment_json,
111+
headers={"x-flagsmith-document-updated-at": "1777306230.090367"},
112+
)
113+
114+
# When
115+
flagsmith.update_environment()
116+
117+
# Then: the HEAD failure is swallowed and the GET path still applies
118+
# the document, so we never lose the ability to refresh.
119+
assert flagsmith._evaluation_context is not None
120+
121+
59122
@responses.activate()
60123
def test_get_environment_flags_calls_api_when_no_local_environment(
61124
api_key: str, flagsmith: Flagsmith, flags_json: str

0 commit comments

Comments
 (0)