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 CHANGED
@@ -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.8"
22
22
  # x-release-please-end
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(self._get_features_url(api_host, client_key))
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.7
3
+ Version: 1.4.8
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
@@ -1,15 +1,15 @@
1
- growthbook/__init__.py,sha256=7k-g5BJSG0vZIYGhL7zsqJaTX2CiBNpWcYJ7N6XwRnU,444
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=TiQ1zsEsBEErKpeqExFm53yXsZjF5mZKPXT_2lFBXdw,41544
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.7.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
12
- growthbook-1.4.7.dist-info/METADATA,sha256=MuP_A9PoMrrHoV__8meCwZTsbh3wVZa2AyxietxD6bs,22726
13
- growthbook-1.4.7.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
14
- growthbook-1.4.7.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
15
- growthbook-1.4.7.dist-info/RECORD,,
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,,