growthbook 1.4.7__tar.gz → 1.4.8__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.8}/PKG-INFO +1 -1
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/__init__.py +1 -1
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/growthbook.py +108 -8
- {growthbook-1.4.7 → growthbook-1.4.8/growthbook.egg-info}/PKG-INFO +1 -1
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook.egg-info/SOURCES.txt +1 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/setup.cfg +1 -1
- growthbook-1.4.8/tests/test_etag.py +345 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/tests/test_growthbook.py +21 -5
- {growthbook-1.4.7 → growthbook-1.4.8}/LICENSE +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/MANIFEST.in +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/README.md +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/common_types.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/core.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/growthbook_client.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/plugins/growthbook_tracking.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/plugins/request_context.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook/py.typed +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/pyproject.toml +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/setup.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/tests/conftest.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/tests/test_growthbook_client.py +0 -0
- {growthbook-1.4.7 → growthbook-1.4.8}/tests/test_plugins.py +0 -0
|
@@ -323,6 +323,10 @@ class SSEClient:
|
|
|
323
323
|
except Exception as e:
|
|
324
324
|
logger.warning(f"Error during SSE task cleanup: {e}")
|
|
325
325
|
|
|
326
|
+
from collections import OrderedDict
|
|
327
|
+
|
|
328
|
+
# ... (imports)
|
|
329
|
+
|
|
326
330
|
class FeatureRepository(object):
|
|
327
331
|
def __init__(self) -> None:
|
|
328
332
|
self.cache: AbstractFeatureCache = InMemoryFeatureCache()
|
|
@@ -334,6 +338,12 @@ class FeatureRepository(object):
|
|
|
334
338
|
self._refresh_thread: Optional[threading.Thread] = None
|
|
335
339
|
self._refresh_stop_event = threading.Event()
|
|
336
340
|
self._refresh_lock = threading.Lock()
|
|
341
|
+
|
|
342
|
+
# ETag cache for bandwidth optimization
|
|
343
|
+
# Using OrderedDict for LRU cache (max 100 entries)
|
|
344
|
+
self._etag_cache: OrderedDict[str, Tuple[str, Dict[str, Any]]] = OrderedDict()
|
|
345
|
+
self._max_etag_entries = 100
|
|
346
|
+
self._etag_lock = threading.Lock()
|
|
337
347
|
|
|
338
348
|
def set_cache(self, cache: AbstractFeatureCache) -> None:
|
|
339
349
|
self.cache = cache
|
|
@@ -400,33 +410,123 @@ class FeatureRepository(object):
|
|
|
400
410
|
return cached
|
|
401
411
|
|
|
402
412
|
# Perform the GET request (separate method for easy mocking)
|
|
403
|
-
def _get(self, url: str):
|
|
413
|
+
def _get(self, url: str, headers: Optional[Dict[str, str]] = None):
|
|
404
414
|
self.http = self.http or PoolManager()
|
|
405
|
-
return self.http.request("GET", url)
|
|
406
|
-
|
|
415
|
+
return self.http.request("GET", url, headers=headers or {})
|
|
416
|
+
|
|
407
417
|
def _fetch_and_decode(self, api_host: str, client_key: str) -> Optional[Dict]:
|
|
418
|
+
url = self._get_features_url(api_host, client_key)
|
|
419
|
+
headers: Dict[str, str] = {}
|
|
420
|
+
|
|
421
|
+
# Check if we have a cached ETag for this URL
|
|
422
|
+
cached_etag = None
|
|
423
|
+
cached_data = None
|
|
424
|
+
with self._etag_lock:
|
|
425
|
+
if url in self._etag_cache:
|
|
426
|
+
# Move to end (mark as recently used)
|
|
427
|
+
self._etag_cache.move_to_end(url)
|
|
428
|
+
cached_etag, cached_data = self._etag_cache[url]
|
|
429
|
+
headers['If-None-Match'] = cached_etag
|
|
430
|
+
logger.debug(f"Using cached ETag for request: {cached_etag[:20]}...")
|
|
431
|
+
else:
|
|
432
|
+
logger.debug(f"No ETag cache found for URL: {url}")
|
|
433
|
+
|
|
408
434
|
try:
|
|
409
|
-
r = self._get(
|
|
435
|
+
r = self._get(url, headers)
|
|
436
|
+
|
|
437
|
+
# Handle 304 Not Modified - content hasn't changed
|
|
438
|
+
if r.status == 304:
|
|
439
|
+
logger.debug(f"ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
|
|
440
|
+
if cached_data is not None:
|
|
441
|
+
logger.debug(f"Returning cached response ({len(str(cached_data))} bytes)")
|
|
442
|
+
return cached_data
|
|
443
|
+
else:
|
|
444
|
+
logger.warning("Received 304 but no cached data available")
|
|
445
|
+
return None
|
|
446
|
+
|
|
410
447
|
if r.status >= 400:
|
|
411
448
|
logger.warning(
|
|
412
449
|
"Failed to fetch features, received status code %d", r.status
|
|
413
450
|
)
|
|
414
451
|
return None
|
|
452
|
+
|
|
415
453
|
decoded = json.loads(r.data.decode("utf-8"))
|
|
454
|
+
|
|
455
|
+
# Store the new ETag if present
|
|
456
|
+
response_etag = r.headers.get('ETag')
|
|
457
|
+
if response_etag:
|
|
458
|
+
with self._etag_lock:
|
|
459
|
+
self._etag_cache[url] = (response_etag, decoded)
|
|
460
|
+
# Enforce max size
|
|
461
|
+
if len(self._etag_cache) > self._max_etag_entries:
|
|
462
|
+
self._etag_cache.popitem(last=False)
|
|
463
|
+
|
|
464
|
+
if cached_etag:
|
|
465
|
+
logger.debug(f"ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
|
|
466
|
+
else:
|
|
467
|
+
logger.debug(f"New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
|
|
468
|
+
logger.debug(f"ETag cache now contains {len(self._etag_cache)} entries")
|
|
469
|
+
else:
|
|
470
|
+
logger.debug("No ETag header in response")
|
|
471
|
+
|
|
416
472
|
return decoded # type: ignore[no-any-return]
|
|
417
|
-
except Exception:
|
|
418
|
-
logger.warning("Failed to decode feature JSON from GrowthBook API")
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.warning(f"Failed to decode feature JSON from GrowthBook API: {e}")
|
|
419
475
|
return None
|
|
420
476
|
|
|
421
477
|
async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optional[Dict]:
|
|
478
|
+
url = self._get_features_url(api_host, client_key)
|
|
479
|
+
headers: Dict[str, str] = {}
|
|
480
|
+
|
|
481
|
+
# Check if we have a cached ETag for this URL
|
|
482
|
+
cached_etag = None
|
|
483
|
+
cached_data = None
|
|
484
|
+
with self._etag_lock:
|
|
485
|
+
if url in self._etag_cache:
|
|
486
|
+
# Move to end (mark as recently used)
|
|
487
|
+
self._etag_cache.move_to_end(url)
|
|
488
|
+
cached_etag, cached_data = self._etag_cache[url]
|
|
489
|
+
headers['If-None-Match'] = cached_etag
|
|
490
|
+
logger.debug(f"[Async] Using cached ETag for request: {cached_etag[:20]}...")
|
|
491
|
+
else:
|
|
492
|
+
logger.debug(f"[Async] No ETag cache found for URL: {url}")
|
|
493
|
+
|
|
422
494
|
try:
|
|
423
|
-
url = self._get_features_url(api_host, client_key)
|
|
424
495
|
async with aiohttp.ClientSession() as session:
|
|
425
|
-
async with session.get(url) as response:
|
|
496
|
+
async with session.get(url, headers=headers) as response:
|
|
497
|
+
# Handle 304 Not Modified - content hasn't changed
|
|
498
|
+
if response.status == 304:
|
|
499
|
+
logger.debug(f"[Async] ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
|
|
500
|
+
if cached_data is not None:
|
|
501
|
+
logger.debug(f"[Async] Returning cached response ({len(str(cached_data))} bytes)")
|
|
502
|
+
return cached_data
|
|
503
|
+
else:
|
|
504
|
+
logger.warning("[Async] Received 304 but no cached data available")
|
|
505
|
+
return None
|
|
506
|
+
|
|
426
507
|
if response.status >= 400:
|
|
427
508
|
logger.warning("Failed to fetch features, received status code %d", response.status)
|
|
428
509
|
return None
|
|
510
|
+
|
|
429
511
|
decoded = await response.json()
|
|
512
|
+
|
|
513
|
+
# Store the new ETag if present
|
|
514
|
+
response_etag = response.headers.get('ETag')
|
|
515
|
+
if response_etag:
|
|
516
|
+
with self._etag_lock:
|
|
517
|
+
self._etag_cache[url] = (response_etag, decoded)
|
|
518
|
+
# Enforce max size
|
|
519
|
+
if len(self._etag_cache) > self._max_etag_entries:
|
|
520
|
+
self._etag_cache.popitem(last=False)
|
|
521
|
+
|
|
522
|
+
if cached_etag:
|
|
523
|
+
logger.debug(f"[Async] ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
|
|
524
|
+
else:
|
|
525
|
+
logger.debug(f"[Async] New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
|
|
526
|
+
logger.debug(f"[Async] ETag cache now contains {len(self._etag_cache)} entries")
|
|
527
|
+
else:
|
|
528
|
+
logger.debug("[Async] No ETag header in response")
|
|
529
|
+
|
|
430
530
|
return decoded # type: ignore[no-any-return]
|
|
431
531
|
except aiohttp.ClientError as e:
|
|
432
532
|
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
|