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 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.9"
22
22
  # x-release-please-end
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(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
@@ -1,15 +1,15 @@
1
- growthbook/__init__.py,sha256=7k-g5BJSG0vZIYGhL7zsqJaTX2CiBNpWcYJ7N6XwRnU,444
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=TiQ1zsEsBEErKpeqExFm53yXsZjF5mZKPXT_2lFBXdw,41544
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.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.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,,