growthbook 1.4.7__tar.gz → 1.4.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {growthbook-1.4.7/growthbook.egg-info → growthbook-1.4.9}/PKG-INFO +1 -1
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/__init__.py +1 -1
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/growthbook.py +111 -8
- {growthbook-1.4.7 → growthbook-1.4.9/growthbook.egg-info}/PKG-INFO +1 -1
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook.egg-info/SOURCES.txt +1 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/setup.cfg +1 -1
- growthbook-1.4.9/tests/test_etag.py +345 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/tests/test_growthbook.py +21 -5
- {growthbook-1.4.7 → growthbook-1.4.9}/LICENSE +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/MANIFEST.in +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/README.md +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/common_types.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/core.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/growthbook_client.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/plugins/growthbook_tracking.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/plugins/request_context.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/py.typed +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/pyproject.toml +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/setup.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/tests/conftest.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/tests/test_growthbook_client.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.9}/tests/test_plugins.py +0 -0
|
@@ -139,6 +139,7 @@ class SSEClient:
|
|
|
139
139
|
self.headers = {
|
|
140
140
|
"Accept": "application/json; q=0.5, text/event-stream",
|
|
141
141
|
"Cache-Control": "no-cache",
|
|
142
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
if headers:
|
|
@@ -323,6 +324,10 @@ class SSEClient:
|
|
|
323
324
|
except Exception as e:
|
|
324
325
|
logger.warning(f"Error during SSE task cleanup: {e}")
|
|
325
326
|
|
|
327
|
+
from collections import OrderedDict
|
|
328
|
+
|
|
329
|
+
# ... (imports)
|
|
330
|
+
|
|
326
331
|
class FeatureRepository(object):
|
|
327
332
|
def __init__(self) -> None:
|
|
328
333
|
self.cache: AbstractFeatureCache = InMemoryFeatureCache()
|
|
@@ -334,6 +339,12 @@ class FeatureRepository(object):
|
|
|
334
339
|
self._refresh_thread: Optional[threading.Thread] = None
|
|
335
340
|
self._refresh_stop_event = threading.Event()
|
|
336
341
|
self._refresh_lock = threading.Lock()
|
|
342
|
+
|
|
343
|
+
# ETag cache for bandwidth optimization
|
|
344
|
+
# Using OrderedDict for LRU cache (max 100 entries)
|
|
345
|
+
self._etag_cache: OrderedDict[str, Tuple[str, Dict[str, Any]]] = OrderedDict()
|
|
346
|
+
self._max_etag_entries = 100
|
|
347
|
+
self._etag_lock = threading.Lock()
|
|
337
348
|
|
|
338
349
|
def set_cache(self, cache: AbstractFeatureCache) -> None:
|
|
339
350
|
self.cache = cache
|
|
@@ -400,33 +411,125 @@ class FeatureRepository(object):
|
|
|
400
411
|
return cached
|
|
401
412
|
|
|
402
413
|
# Perform the GET request (separate method for easy mocking)
|
|
403
|
-
def _get(self, url: str):
|
|
414
|
+
def _get(self, url: str, headers: Optional[Dict[str, str]] = None):
|
|
404
415
|
self.http = self.http or PoolManager()
|
|
405
|
-
return self.http.request("GET", url)
|
|
406
|
-
|
|
416
|
+
return self.http.request("GET", url, headers=headers or {})
|
|
417
|
+
|
|
407
418
|
def _fetch_and_decode(self, api_host: str, client_key: str) -> Optional[Dict]:
|
|
419
|
+
url = self._get_features_url(api_host, client_key)
|
|
420
|
+
headers: Dict[str, str] = {}
|
|
421
|
+
headers['Accept-Encoding'] = "gzip, deflate, br"
|
|
422
|
+
|
|
423
|
+
# Check if we have a cached ETag for this URL
|
|
424
|
+
cached_etag = None
|
|
425
|
+
cached_data = None
|
|
426
|
+
with self._etag_lock:
|
|
427
|
+
if url in self._etag_cache:
|
|
428
|
+
# Move to end (mark as recently used)
|
|
429
|
+
self._etag_cache.move_to_end(url)
|
|
430
|
+
cached_etag, cached_data = self._etag_cache[url]
|
|
431
|
+
headers['If-None-Match'] = cached_etag
|
|
432
|
+
logger.debug(f"Using cached ETag for request: {cached_etag[:20]}...")
|
|
433
|
+
else:
|
|
434
|
+
logger.debug(f"No ETag cache found for URL: {url}")
|
|
435
|
+
|
|
408
436
|
try:
|
|
409
|
-
r = self._get(
|
|
437
|
+
r = self._get(url, headers)
|
|
438
|
+
|
|
439
|
+
# Handle 304 Not Modified - content hasn't changed
|
|
440
|
+
if r.status == 304:
|
|
441
|
+
logger.debug(f"ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
|
|
442
|
+
if cached_data is not None:
|
|
443
|
+
logger.debug(f"Returning cached response ({len(str(cached_data))} bytes)")
|
|
444
|
+
return cached_data
|
|
445
|
+
else:
|
|
446
|
+
logger.warning("Received 304 but no cached data available")
|
|
447
|
+
return None
|
|
448
|
+
|
|
410
449
|
if r.status >= 400:
|
|
411
450
|
logger.warning(
|
|
412
451
|
"Failed to fetch features, received status code %d", r.status
|
|
413
452
|
)
|
|
414
453
|
return None
|
|
454
|
+
|
|
415
455
|
decoded = json.loads(r.data.decode("utf-8"))
|
|
456
|
+
|
|
457
|
+
# Store the new ETag if present
|
|
458
|
+
response_etag = r.headers.get('ETag')
|
|
459
|
+
if response_etag:
|
|
460
|
+
with self._etag_lock:
|
|
461
|
+
self._etag_cache[url] = (response_etag, decoded)
|
|
462
|
+
# Enforce max size
|
|
463
|
+
if len(self._etag_cache) > self._max_etag_entries:
|
|
464
|
+
self._etag_cache.popitem(last=False)
|
|
465
|
+
|
|
466
|
+
if cached_etag:
|
|
467
|
+
logger.debug(f"ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
|
|
468
|
+
else:
|
|
469
|
+
logger.debug(f"New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
|
|
470
|
+
logger.debug(f"ETag cache now contains {len(self._etag_cache)} entries")
|
|
471
|
+
else:
|
|
472
|
+
logger.debug("No ETag header in response")
|
|
473
|
+
|
|
416
474
|
return decoded # type: ignore[no-any-return]
|
|
417
|
-
except Exception:
|
|
418
|
-
logger.warning("Failed to decode feature JSON from GrowthBook API")
|
|
475
|
+
except Exception as e:
|
|
476
|
+
logger.warning(f"Failed to decode feature JSON from GrowthBook API: {e}")
|
|
419
477
|
return None
|
|
420
478
|
|
|
421
479
|
async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optional[Dict]:
|
|
480
|
+
url = self._get_features_url(api_host, client_key)
|
|
481
|
+
headers: Dict[str, str] = {}
|
|
482
|
+
headers['Accept-Encoding'] = "gzip, deflate, br"
|
|
483
|
+
|
|
484
|
+
# Check if we have a cached ETag for this URL
|
|
485
|
+
cached_etag = None
|
|
486
|
+
cached_data = None
|
|
487
|
+
with self._etag_lock:
|
|
488
|
+
if url in self._etag_cache:
|
|
489
|
+
# Move to end (mark as recently used)
|
|
490
|
+
self._etag_cache.move_to_end(url)
|
|
491
|
+
cached_etag, cached_data = self._etag_cache[url]
|
|
492
|
+
headers['If-None-Match'] = cached_etag
|
|
493
|
+
logger.debug(f"[Async] Using cached ETag for request: {cached_etag[:20]}...")
|
|
494
|
+
else:
|
|
495
|
+
logger.debug(f"[Async] No ETag cache found for URL: {url}")
|
|
496
|
+
|
|
422
497
|
try:
|
|
423
|
-
url = self._get_features_url(api_host, client_key)
|
|
424
498
|
async with aiohttp.ClientSession() as session:
|
|
425
|
-
async with session.get(url) as response:
|
|
499
|
+
async with session.get(url, headers=headers) as response:
|
|
500
|
+
# Handle 304 Not Modified - content hasn't changed
|
|
501
|
+
if response.status == 304:
|
|
502
|
+
logger.debug(f"[Async] ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
|
|
503
|
+
if cached_data is not None:
|
|
504
|
+
logger.debug(f"[Async] Returning cached response ({len(str(cached_data))} bytes)")
|
|
505
|
+
return cached_data
|
|
506
|
+
else:
|
|
507
|
+
logger.warning("[Async] Received 304 but no cached data available")
|
|
508
|
+
return None
|
|
509
|
+
|
|
426
510
|
if response.status >= 400:
|
|
427
511
|
logger.warning("Failed to fetch features, received status code %d", response.status)
|
|
428
512
|
return None
|
|
513
|
+
|
|
429
514
|
decoded = await response.json()
|
|
515
|
+
|
|
516
|
+
# Store the new ETag if present
|
|
517
|
+
response_etag = response.headers.get('ETag')
|
|
518
|
+
if response_etag:
|
|
519
|
+
with self._etag_lock:
|
|
520
|
+
self._etag_cache[url] = (response_etag, decoded)
|
|
521
|
+
# Enforce max size
|
|
522
|
+
if len(self._etag_cache) > self._max_etag_entries:
|
|
523
|
+
self._etag_cache.popitem(last=False)
|
|
524
|
+
|
|
525
|
+
if cached_etag:
|
|
526
|
+
logger.debug(f"[Async] ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
|
|
527
|
+
else:
|
|
528
|
+
logger.debug(f"[Async] New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
|
|
529
|
+
logger.debug(f"[Async] ETag cache now contains {len(self._etag_cache)} entries")
|
|
530
|
+
else:
|
|
531
|
+
logger.debug("[Async] No ETag header in response")
|
|
532
|
+
|
|
430
533
|
return decoded # type: ignore[no-any-return]
|
|
431
534
|
except aiohttp.ClientError as e:
|
|
432
535
|
logger.warning(f"HTTP request failed: {e}")
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for ETag caching functionality in GrowthBook SDK.
|
|
3
|
+
|
|
4
|
+
This test suite verifies that the SDK correctly implements ETag-based
|
|
5
|
+
HTTP caching to reduce bandwidth and CDN load.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import pytest
|
|
9
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
10
|
+
from growthbook.growthbook import feature_repo, FeatureRepository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MockResponse:
|
|
14
|
+
"""Mock HTTP response for testing"""
|
|
15
|
+
def __init__(self, status, data, headers=None):
|
|
16
|
+
self.status = status
|
|
17
|
+
self.data = data.encode('utf-8') if isinstance(data, str) else data
|
|
18
|
+
self.headers = headers or {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MockAsyncResponse:
|
|
22
|
+
"""Mock async HTTP response for testing"""
|
|
23
|
+
def __init__(self, status, data, headers=None):
|
|
24
|
+
self.status = status
|
|
25
|
+
self._data = data
|
|
26
|
+
self.headers = headers or {}
|
|
27
|
+
|
|
28
|
+
async def json(self):
|
|
29
|
+
return json.loads(self._data) if isinstance(self._data, str) else self._data
|
|
30
|
+
|
|
31
|
+
def __aenter__(self):
|
|
32
|
+
async def _aenter():
|
|
33
|
+
return self
|
|
34
|
+
return _aenter()
|
|
35
|
+
|
|
36
|
+
def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
37
|
+
async def _aexit():
|
|
38
|
+
pass
|
|
39
|
+
return _aexit()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture(autouse=True)
|
|
43
|
+
def cleanup_etag_cache():
|
|
44
|
+
"""Clear ETag cache before each test"""
|
|
45
|
+
feature_repo._etag_cache.clear()
|
|
46
|
+
yield
|
|
47
|
+
feature_repo._etag_cache.clear()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestETags:
|
|
51
|
+
"""Test ETag caching functionality"""
|
|
52
|
+
|
|
53
|
+
def test_etag_initial_request_no_cache(self):
|
|
54
|
+
"""First request should not send If-None-Match header"""
|
|
55
|
+
features_response = json.dumps({
|
|
56
|
+
"features": {"feature1": {"defaultValue": True}}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
60
|
+
mock_get.return_value = MockResponse(
|
|
61
|
+
status=200,
|
|
62
|
+
data=features_response,
|
|
63
|
+
headers={'ETag': '"abc123"'}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
result = feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
67
|
+
|
|
68
|
+
# Verify request was made without If-None-Match
|
|
69
|
+
assert mock_get.called
|
|
70
|
+
call_args = mock_get.call_args
|
|
71
|
+
headers = call_args[1].get('headers', {})
|
|
72
|
+
assert 'If-None-Match' not in headers
|
|
73
|
+
|
|
74
|
+
# Verify response was returned
|
|
75
|
+
assert result is not None
|
|
76
|
+
assert "features" in result
|
|
77
|
+
|
|
78
|
+
# Verify ETag was cached
|
|
79
|
+
url = feature_repo._get_features_url("https://cdn.growthbook.io", "test_key")
|
|
80
|
+
assert url in feature_repo._etag_cache
|
|
81
|
+
cached_etag, cached_data = feature_repo._etag_cache[url]
|
|
82
|
+
assert cached_etag == '"abc123"'
|
|
83
|
+
assert cached_data == result
|
|
84
|
+
|
|
85
|
+
def test_etag_second_request_sends_if_none_match(self):
|
|
86
|
+
"""Second request should send If-None-Match header with cached ETag"""
|
|
87
|
+
features_response = json.dumps({
|
|
88
|
+
"features": {"feature1": {"defaultValue": True}}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
92
|
+
# First request - returns 200 with ETag
|
|
93
|
+
mock_get.return_value = MockResponse(
|
|
94
|
+
status=200,
|
|
95
|
+
data=features_response,
|
|
96
|
+
headers={'ETag': '"abc123"'}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
100
|
+
|
|
101
|
+
# Second request - should send If-None-Match
|
|
102
|
+
mock_get.reset_mock()
|
|
103
|
+
mock_get.return_value = MockResponse(
|
|
104
|
+
status=200,
|
|
105
|
+
data=features_response,
|
|
106
|
+
headers={'ETag': '"abc123"'}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
110
|
+
|
|
111
|
+
# Verify If-None-Match was sent
|
|
112
|
+
call_args = mock_get.call_args
|
|
113
|
+
# Headers are passed as positional or keyword argument
|
|
114
|
+
if len(call_args[0]) > 1:
|
|
115
|
+
headers = call_args[0][1] if isinstance(call_args[0][1], dict) else {}
|
|
116
|
+
else:
|
|
117
|
+
headers = call_args[1].get('headers', {})
|
|
118
|
+
assert 'If-None-Match' in headers
|
|
119
|
+
assert headers['If-None-Match'] == '"abc123"'
|
|
120
|
+
|
|
121
|
+
def test_etag_304_returns_cached_data(self):
|
|
122
|
+
"""Server returning 304 should return cached data"""
|
|
123
|
+
features_response = json.dumps({
|
|
124
|
+
"features": {"feature1": {"defaultValue": True}}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
128
|
+
# First request - returns 200 with ETag
|
|
129
|
+
mock_get.return_value = MockResponse(
|
|
130
|
+
status=200,
|
|
131
|
+
data=features_response,
|
|
132
|
+
headers={'ETag': '"abc123"'}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
first_result = feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
136
|
+
|
|
137
|
+
# Second request - returns 304 Not Modified
|
|
138
|
+
mock_get.return_value = MockResponse(
|
|
139
|
+
status=304,
|
|
140
|
+
data="", # 304 responses have no body
|
|
141
|
+
headers={'ETag': '"abc123"'}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
second_result = feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
145
|
+
|
|
146
|
+
# Verify cached data was returned
|
|
147
|
+
assert second_result is not None
|
|
148
|
+
assert second_result == first_result
|
|
149
|
+
assert "features" in second_result
|
|
150
|
+
|
|
151
|
+
def test_etag_updated_on_content_change(self):
|
|
152
|
+
"""ETag should be updated when content changes"""
|
|
153
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
154
|
+
# First request - initial ETag
|
|
155
|
+
mock_get.return_value = MockResponse(
|
|
156
|
+
status=200,
|
|
157
|
+
data=json.dumps({"features": {"feature1": {"defaultValue": True}}}),
|
|
158
|
+
headers={'ETag': '"abc123"'}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
162
|
+
|
|
163
|
+
url = feature_repo._get_features_url("https://cdn.growthbook.io", "test_key")
|
|
164
|
+
assert feature_repo._etag_cache[url][0] == '"abc123"'
|
|
165
|
+
|
|
166
|
+
# Second request - new ETag (content changed)
|
|
167
|
+
mock_get.return_value = MockResponse(
|
|
168
|
+
status=200,
|
|
169
|
+
data=json.dumps({"features": {"feature2": {"defaultValue": False}}}),
|
|
170
|
+
headers={'ETag': '"xyz789"'}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
result = feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
174
|
+
|
|
175
|
+
# Verify ETag was updated
|
|
176
|
+
assert feature_repo._etag_cache[url][0] == '"xyz789"'
|
|
177
|
+
assert "feature2" in result["features"]
|
|
178
|
+
|
|
179
|
+
def test_etag_no_header_still_works(self):
|
|
180
|
+
"""Requests should work even if server doesn't send ETag"""
|
|
181
|
+
features_response = json.dumps({
|
|
182
|
+
"features": {"feature1": {"defaultValue": True}}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
186
|
+
mock_get.return_value = MockResponse(
|
|
187
|
+
status=200,
|
|
188
|
+
data=features_response,
|
|
189
|
+
headers={} # No ETag header
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
result = feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
193
|
+
|
|
194
|
+
# Should still return data
|
|
195
|
+
assert result is not None
|
|
196
|
+
assert "features" in result
|
|
197
|
+
|
|
198
|
+
# But ETag should not be cached
|
|
199
|
+
url = feature_repo._get_features_url("https://cdn.growthbook.io", "test_key")
|
|
200
|
+
assert url not in feature_repo._etag_cache
|
|
201
|
+
|
|
202
|
+
def test_etag_multiple_urls_cached_separately(self):
|
|
203
|
+
"""Different URLs should have separate ETag cache entries"""
|
|
204
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
205
|
+
# Request for key1
|
|
206
|
+
mock_get.return_value = MockResponse(
|
|
207
|
+
status=200,
|
|
208
|
+
data=json.dumps({"features": {"feature1": {"defaultValue": True}}}),
|
|
209
|
+
headers={'ETag': '"etag-key1"'}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", "key1")
|
|
213
|
+
|
|
214
|
+
# Request for key2
|
|
215
|
+
mock_get.return_value = MockResponse(
|
|
216
|
+
status=200,
|
|
217
|
+
data=json.dumps({"features": {"feature2": {"defaultValue": False}}}),
|
|
218
|
+
headers={'ETag': '"etag-key2"'}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", "key2")
|
|
222
|
+
|
|
223
|
+
# Verify both are cached with different ETags
|
|
224
|
+
url1 = feature_repo._get_features_url("https://cdn.growthbook.io", "key1")
|
|
225
|
+
url2 = feature_repo._get_features_url("https://cdn.growthbook.io", "key2")
|
|
226
|
+
|
|
227
|
+
assert url1 in feature_repo._etag_cache
|
|
228
|
+
assert url2 in feature_repo._etag_cache
|
|
229
|
+
assert feature_repo._etag_cache[url1][0] == '"etag-key1"'
|
|
230
|
+
assert feature_repo._etag_cache[url2][0] == '"etag-key2"'
|
|
231
|
+
|
|
232
|
+
def test_etag_bandwidth_savings(self):
|
|
233
|
+
"""Test that 304 responses save bandwidth compared to full responses"""
|
|
234
|
+
large_features = {
|
|
235
|
+
"features": {f"feature{i}": {"defaultValue": True} for i in range(100)}
|
|
236
|
+
}
|
|
237
|
+
features_response = json.dumps(large_features)
|
|
238
|
+
|
|
239
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
240
|
+
# First request - full response
|
|
241
|
+
mock_get.return_value = MockResponse(
|
|
242
|
+
status=200,
|
|
243
|
+
data=features_response,
|
|
244
|
+
headers={'ETag': '"large-response"'}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
first_result = feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
248
|
+
first_size = len(features_response)
|
|
249
|
+
|
|
250
|
+
# Second request - 304 response (minimal data)
|
|
251
|
+
mock_get.return_value = MockResponse(
|
|
252
|
+
status=304,
|
|
253
|
+
data="", # No body in 304
|
|
254
|
+
headers={'ETag': '"large-response"'}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
second_result = feature_repo._fetch_and_decode("https://cdn.growthbook.io", "test_key")
|
|
258
|
+
second_size = 0 # 304 has no body
|
|
259
|
+
|
|
260
|
+
# Verify we got the same data
|
|
261
|
+
assert first_result == second_result
|
|
262
|
+
|
|
263
|
+
# Verify bandwidth savings
|
|
264
|
+
assert first_size > 1000 # Large response
|
|
265
|
+
assert second_size == 0 # 304 response has no body
|
|
266
|
+
print(f"\n💾 Bandwidth saved: {first_size} bytes (100% savings on cache hit)")
|
|
267
|
+
|
|
268
|
+
def test_etag_cache_persistence_across_requests(self):
|
|
269
|
+
"""Test that ETag cache persists across multiple different requests"""
|
|
270
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
271
|
+
# Make 3 requests to different keys
|
|
272
|
+
for i in range(3):
|
|
273
|
+
mock_get.return_value = MockResponse(
|
|
274
|
+
status=200,
|
|
275
|
+
data=json.dumps({"features": {f"feature{i}": {}}}),
|
|
276
|
+
headers={'ETag': f'"etag-{i}"'}
|
|
277
|
+
)
|
|
278
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", f"key{i}")
|
|
279
|
+
|
|
280
|
+
# Verify all 3 ETags are cached
|
|
281
|
+
assert len(feature_repo._etag_cache) == 3
|
|
282
|
+
|
|
283
|
+
# Verify each ETag is correct
|
|
284
|
+
for i in range(3):
|
|
285
|
+
url = feature_repo._get_features_url("https://cdn.growthbook.io", f"key{i}")
|
|
286
|
+
assert url in feature_repo._etag_cache
|
|
287
|
+
assert feature_repo._etag_cache[url][0] == f'"etag-{i}"'
|
|
288
|
+
|
|
289
|
+
def test_etag_cache_initialized(self):
|
|
290
|
+
"""ETag cache should be initialized on FeatureRepository instantiation"""
|
|
291
|
+
assert hasattr(feature_repo, '_etag_cache')
|
|
292
|
+
assert isinstance(feature_repo._etag_cache, dict)
|
|
293
|
+
|
|
294
|
+
def test_etag_cache_lru_eviction(self):
|
|
295
|
+
"""Test that ETag cache enforces size limit and LRU eviction"""
|
|
296
|
+
# Set a small limit for testing
|
|
297
|
+
feature_repo._max_etag_entries = 3
|
|
298
|
+
|
|
299
|
+
with patch.object(feature_repo, '_get') as mock_get:
|
|
300
|
+
# Fill cache to limit
|
|
301
|
+
for i in range(3):
|
|
302
|
+
mock_get.return_value = MockResponse(
|
|
303
|
+
status=200,
|
|
304
|
+
data=json.dumps({"features": {f"feature{i}": {}}}),
|
|
305
|
+
headers={'ETag': f'"etag-{i}"'}
|
|
306
|
+
)
|
|
307
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", f"key{i}")
|
|
308
|
+
|
|
309
|
+
assert len(feature_repo._etag_cache) == 3
|
|
310
|
+
assert "https://cdn.growthbook.io/api/features/key0" in feature_repo._etag_cache
|
|
311
|
+
|
|
312
|
+
# Add one more item - should evict key0 (least recently used)
|
|
313
|
+
mock_get.return_value = MockResponse(
|
|
314
|
+
status=200,
|
|
315
|
+
data=json.dumps({"features": {"feature3": {}}}),
|
|
316
|
+
headers={'ETag': '"etag-3"'}
|
|
317
|
+
)
|
|
318
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", "key3")
|
|
319
|
+
|
|
320
|
+
assert len(feature_repo._etag_cache) == 3
|
|
321
|
+
assert "https://cdn.growthbook.io/api/features/key0" not in feature_repo._etag_cache
|
|
322
|
+
assert "https://cdn.growthbook.io/api/features/key3" in feature_repo._etag_cache
|
|
323
|
+
|
|
324
|
+
# Access key1 to make it most recently used
|
|
325
|
+
mock_get.return_value = MockResponse(
|
|
326
|
+
status=304,
|
|
327
|
+
data="",
|
|
328
|
+
headers={'ETag': '"etag-1"'}
|
|
329
|
+
)
|
|
330
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", "key1")
|
|
331
|
+
|
|
332
|
+
# Add another item - should evict key2 (now least recently used)
|
|
333
|
+
mock_get.return_value = MockResponse(
|
|
334
|
+
status=200,
|
|
335
|
+
data=json.dumps({"features": {"feature4": {}}}),
|
|
336
|
+
headers={'ETag': '"etag-4"'}
|
|
337
|
+
)
|
|
338
|
+
feature_repo._fetch_and_decode("https://cdn.growthbook.io", "key4")
|
|
339
|
+
|
|
340
|
+
assert len(feature_repo._etag_cache) == 3
|
|
341
|
+
assert "https://cdn.growthbook.io/api/features/key2" not in feature_repo._etag_cache
|
|
342
|
+
assert "https://cdn.growthbook.io/api/features/key1" in feature_repo._etag_cache # Preserved!
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
pytest.main([__file__, "-v"])
|
|
@@ -799,6 +799,7 @@ class MockHttpResp:
|
|
|
799
799
|
def __init__(self, status: int, data: str) -> None:
|
|
800
800
|
self.status = status
|
|
801
801
|
self.data = data.encode("utf-8")
|
|
802
|
+
self.headers = {} # Add headers attribute for ETag support
|
|
802
803
|
|
|
803
804
|
|
|
804
805
|
def test_feature_repository(mocker):
|
|
@@ -807,7 +808,10 @@ def test_feature_repository(mocker):
|
|
|
807
808
|
m.return_value = MockHttpResp(200, json.dumps(expected))
|
|
808
809
|
features = feature_repo.load_features("https://cdn.growthbook.io", "sdk-abc123")
|
|
809
810
|
|
|
810
|
-
|
|
811
|
+
# Updated assertion to account for headers parameter
|
|
812
|
+
assert m.call_count == 1
|
|
813
|
+
call_args = m.call_args[0]
|
|
814
|
+
assert call_args[0] == "https://cdn.growthbook.io/api/features/sdk-abc123"
|
|
811
815
|
assert features == expected
|
|
812
816
|
|
|
813
817
|
# Uses in-memory cache for the 2nd call
|
|
@@ -831,7 +835,10 @@ def test_feature_repository_error(mocker):
|
|
|
831
835
|
m.return_value = MockHttpResp(400, "400 Error")
|
|
832
836
|
features = feature_repo.load_features("https://cdn.growthbook.io", "sdk-abc123")
|
|
833
837
|
|
|
834
|
-
|
|
838
|
+
# Updated assertion to account for headers parameter
|
|
839
|
+
assert m.call_count == 1
|
|
840
|
+
call_args = m.call_args[0]
|
|
841
|
+
assert call_args[0] == "https://cdn.growthbook.io/api/features/sdk-abc123"
|
|
835
842
|
assert features is None
|
|
836
843
|
|
|
837
844
|
# Does not cache errors
|
|
@@ -863,7 +870,10 @@ def test_feature_repository_encrypted(mocker):
|
|
|
863
870
|
"https://cdn.growthbook.io", "sdk-abc123", "Zvwv/+uhpFDznZ6SX28Yjg=="
|
|
864
871
|
)
|
|
865
872
|
|
|
866
|
-
|
|
873
|
+
# Updated assertion to account for headers parameter
|
|
874
|
+
assert m.call_count == 1
|
|
875
|
+
call_args = m.call_args[0]
|
|
876
|
+
assert call_args[0] == "https://cdn.growthbook.io/api/features/sdk-abc123"
|
|
867
877
|
assert features == {"features": {"feature": {"defaultValue": True}}}
|
|
868
878
|
|
|
869
879
|
feature_repo.clear_cache()
|
|
@@ -884,7 +894,10 @@ def test_load_features(mocker):
|
|
|
884
894
|
assert m.call_count == 0
|
|
885
895
|
|
|
886
896
|
gb.load_features()
|
|
887
|
-
|
|
897
|
+
# Updated assertion to account for headers parameter
|
|
898
|
+
assert m.call_count == 1
|
|
899
|
+
call_args = m.call_args[0]
|
|
900
|
+
assert call_args[0] == "https://cdn.growthbook.io/api/features/sdk-abc123"
|
|
888
901
|
|
|
889
902
|
assert gb.get_features()["feature"].to_dict() == {"defaultValue": 5, "rules": []}
|
|
890
903
|
|
|
@@ -944,7 +957,10 @@ def test_loose_unmarshalling(mocker):
|
|
|
944
957
|
assert m.call_count == 0
|
|
945
958
|
|
|
946
959
|
gb.load_features()
|
|
947
|
-
|
|
960
|
+
# Updated assertion to account for headers parameter
|
|
961
|
+
assert m.call_count == 1
|
|
962
|
+
call_args = m.call_args[0]
|
|
963
|
+
assert call_args[0] == "https://cdn.growthbook.io/api/features/sdk-abc123"
|
|
948
964
|
|
|
949
965
|
assert gb.get_features()["feature"].to_dict() == {
|
|
950
966
|
"defaultValue": 5,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|