growthbook 1.4.7__py2.py3-none-any.whl → 1.4.8__py2.py3-none-any.whl
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/__init__.py +1 -1
- growthbook/growthbook.py +108 -8
- {growthbook-1.4.7.dist-info → growthbook-1.4.8.dist-info}/METADATA +1 -1
- {growthbook-1.4.7.dist-info → growthbook-1.4.8.dist-info}/RECORD +7 -7
- {growthbook-1.4.7.dist-info → growthbook-1.4.8.dist-info}/WHEEL +0 -0
- {growthbook-1.4.7.dist-info → growthbook-1.4.8.dist-info}/licenses/LICENSE +0 -0
- {growthbook-1.4.7.dist-info → growthbook-1.4.8.dist-info}/top_level.txt +0 -0
growthbook/__init__.py
CHANGED
growthbook/growthbook.py
CHANGED
|
@@ -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}")
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
growthbook/__init__.py,sha256=
|
|
1
|
+
growthbook/__init__.py,sha256=BwGsRsv5njoTII6ZRvPJZg96aPrOC4s0KFJbGbWI5m0,444
|
|
2
2
|
growthbook/common_types.py,sha256=YKUmmYfzgrzLQ7kp2IPLc8QBA-B0QbnbF5viekNiTpw,15703
|
|
3
3
|
growthbook/core.py,sha256=C1Nes_AiEuu6ypPghKuIeM2F22XUsLK1KrLW0xDwLYU,35963
|
|
4
|
-
growthbook/growthbook.py,sha256=
|
|
4
|
+
growthbook/growthbook.py,sha256=CzBahR87qYySyWT2-JLmmWtb70Xr9PtknVorPtfHftw,46479
|
|
5
5
|
growthbook/growthbook_client.py,sha256=ZzdeNZ1a9N78ISbj2BKN9Xmyt05VlPVNjMyh9E1eA0E,24679
|
|
6
6
|
growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
growthbook/plugins/__init__.py,sha256=y2eAV1sA041XWcftBVTDH0t-ggy9r2C5oKRYRF6XR6s,602
|
|
8
8
|
growthbook/plugins/base.py,sha256=PWBXUBj62hi25Y5Eif9WmEWagWdkwGXHi2dMtn44bo8,3637
|
|
9
9
|
growthbook/plugins/growthbook_tracking.py,sha256=lWO9ErUSrnqhcpWLp03XIrh45-BdBssdmLDVvaGvulY,11317
|
|
10
10
|
growthbook/plugins/request_context.py,sha256=WzoGxalxPfrsN3RzfkvVYaUGat1A3N4AErnaS9IZ48Y,13005
|
|
11
|
-
growthbook-1.4.
|
|
12
|
-
growthbook-1.4.
|
|
13
|
-
growthbook-1.4.
|
|
14
|
-
growthbook-1.4.
|
|
15
|
-
growthbook-1.4.
|
|
11
|
+
growthbook-1.4.8.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
|
|
12
|
+
growthbook-1.4.8.dist-info/METADATA,sha256=ogrwnsEpsEZfwGsT3WtnMNbxecVH8abUWlXA2svIQOc,22726
|
|
13
|
+
growthbook-1.4.8.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
|
|
14
|
+
growthbook-1.4.8.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
|
|
15
|
+
growthbook-1.4.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|