growthbook 1.4.7__py2.py3-none-any.whl → 1.4.9__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 +111 -8
- {growthbook-1.4.7.dist-info → growthbook-1.4.9.dist-info}/METADATA +1 -1
- {growthbook-1.4.7.dist-info → growthbook-1.4.9.dist-info}/RECORD +7 -7
- {growthbook-1.4.7.dist-info → growthbook-1.4.9.dist-info}/WHEEL +0 -0
- {growthbook-1.4.7.dist-info → growthbook-1.4.9.dist-info}/licenses/LICENSE +0 -0
- {growthbook-1.4.7.dist-info → growthbook-1.4.9.dist-info}/top_level.txt +0 -0
growthbook/__init__.py
CHANGED
growthbook/growthbook.py
CHANGED
|
@@ -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}")
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
growthbook/__init__.py,sha256=
|
|
1
|
+
growthbook/__init__.py,sha256=rS8Pbu4YzSoxgDLLEOGAQ8LBKOmI1OoTTpcEDleWr-g,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=DNvkUyWkpWqYt1d73XyzfgXTFqmzIUWOcBueEIgVu_k,46645
|
|
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.9.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
|
|
12
|
+
growthbook-1.4.9.dist-info/METADATA,sha256=USHNtbSqd4Wic7sVYdEkoGJ_wzhs58L7dbi96msdv4A,22726
|
|
13
|
+
growthbook-1.4.9.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
|
|
14
|
+
growthbook-1.4.9.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
|
|
15
|
+
growthbook-1.4.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|