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.
Files changed (27) hide show
  1. {growthbook-1.4.7/growthbook.egg-info → growthbook-1.4.9}/PKG-INFO +1 -1
  2. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/__init__.py +1 -1
  3. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/growthbook.py +111 -8
  4. {growthbook-1.4.7 → growthbook-1.4.9/growthbook.egg-info}/PKG-INFO +1 -1
  5. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook.egg-info/SOURCES.txt +1 -0
  6. {growthbook-1.4.7 → growthbook-1.4.9}/setup.cfg +1 -1
  7. growthbook-1.4.9/tests/test_etag.py +345 -0
  8. {growthbook-1.4.7 → growthbook-1.4.9}/tests/test_growthbook.py +21 -5
  9. {growthbook-1.4.7 → growthbook-1.4.9}/LICENSE +0 -0
  10. {growthbook-1.4.7 → growthbook-1.4.9}/MANIFEST.in +0 -0
  11. {growthbook-1.4.7 → growthbook-1.4.9}/README.md +0 -0
  12. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/common_types.py +0 -0
  13. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/core.py +0 -0
  14. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/growthbook_client.py +0 -0
  15. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/plugins/__init__.py +0 -0
  16. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/plugins/base.py +0 -0
  17. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/plugins/growthbook_tracking.py +0 -0
  18. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/plugins/request_context.py +0 -0
  19. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook/py.typed +0 -0
  20. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook.egg-info/dependency_links.txt +0 -0
  21. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook.egg-info/requires.txt +0 -0
  22. {growthbook-1.4.7 → growthbook-1.4.9}/growthbook.egg-info/top_level.txt +0 -0
  23. {growthbook-1.4.7 → growthbook-1.4.9}/pyproject.toml +0 -0
  24. {growthbook-1.4.7 → growthbook-1.4.9}/setup.py +0 -0
  25. {growthbook-1.4.7 → growthbook-1.4.9}/tests/conftest.py +0 -0
  26. {growthbook-1.4.7 → growthbook-1.4.9}/tests/test_growthbook_client.py +0 -0
  27. {growthbook-1.4.7 → growthbook-1.4.9}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.7
3
+ Version: 1.4.9
4
4
  Summary: Powerful Feature flagging and A/B testing for Python apps
5
5
  Home-page: https://github.com/growthbook/growthbook-python
6
6
  Author: GrowthBook
@@ -18,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "1.4.7"
21
+ __version__ = "1.4.9"
22
22
  # x-release-please-end
@@ -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(self._get_features_url(api_host, client_key))
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}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.7
3
+ Version: 1.4.9
4
4
  Summary: Powerful Feature flagging and A/B testing for Python apps
5
5
  Home-page: https://github.com/growthbook/growthbook-python
6
6
  Author: GrowthBook
@@ -20,6 +20,7 @@ growthbook/plugins/base.py
20
20
  growthbook/plugins/growthbook_tracking.py
21
21
  growthbook/plugins/request_context.py
22
22
  tests/conftest.py
23
+ tests/test_etag.py
23
24
  tests/test_growthbook.py
24
25
  tests/test_growthbook_client.py
25
26
  tests/test_plugins.py
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 1.4.7
2
+ current_version = 1.4.9
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -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
- m.assert_called_once_with("https://cdn.growthbook.io/api/features/sdk-abc123")
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
- m.assert_called_once_with("https://cdn.growthbook.io/api/features/sdk-abc123")
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
- m.assert_called_once_with("https://cdn.growthbook.io/api/features/sdk-abc123")
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
- m.assert_called_once_with("https://cdn.growthbook.io/api/features/sdk-abc123")
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
- m.assert_called_once_with("https://cdn.growthbook.io/api/features/sdk-abc123")
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