Coverage for server / services / growthbook / client.py: 100%

87 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1""" 

2GrowthBook SDK client — loads feature definitions on an interval (no per-request HTTP). 

3""" 

4 

5import asyncio 

6import logging 

7from typing import Any, Optional 

8 

9from server.services.growthbook.config import get_growthbook_settings 

10from server.services.growthbook.context import TargetingContext 

11from server.services.growthbook.registry import registry_default_enabled 

12 

13logger = logging.getLogger(__name__) 

14 

15 

16class GrowthBookClientWrapper: 

17 """Thin wrapper around growthbook-python GrowthBookClient.""" 

18 

19 def __init__(self) -> None: 

20 self._client: Any = None 

21 self._initialized = False 

22 self._lock = asyncio.Lock() 

23 

24 @property 

25 def is_ready(self) -> bool: 

26 return self._initialized and self._client is not None 

27 

28 def list_feature_keys(self) -> list[str]: 

29 if not self.is_ready or self._client is None: 

30 return [] 

31 global_ctx = getattr(self._client, "_global_context", None) 

32 if global_ctx is None or not global_ctx.features: 

33 return [] 

34 return sorted(global_ctx.features.keys()) 

35 

36 def has_feature(self, feature_key: str) -> bool: 

37 return feature_key in self.list_feature_keys() 

38 

39 def get_feature_default_value(self, feature_key: str) -> Optional[bool]: 

40 if not self.is_ready or self._client is None: 

41 return None 

42 global_ctx = getattr(self._client, "_global_context", None) 

43 if global_ctx is None: 

44 return None 

45 feature = global_ctx.features.get(feature_key) 

46 if feature is None: 

47 return None 

48 default = feature.defaultValue 

49 if isinstance(default, bool): 

50 return default 

51 if default is None: 

52 return False 

53 return bool(default) 

54 

55 async def start(self) -> None: 

56 settings = get_growthbook_settings() 

57 if not settings.growthbook_enabled: 

58 logger.info("GrowthBook disabled (GROWTHBOOK_ENABLED=false)") 

59 return 

60 if not settings.growthbook_client_key: 

61 logger.warning( 

62 "GrowthBook enabled but GROWTHBOOK_CLIENT_KEY is empty — using plan-tier defaults only" 

63 ) 

64 return 

65 

66 try: 

67 from growthbook import GrowthBookClient, Options 

68 

69 options = Options( 

70 api_host=settings.growthbook_api_host.rstrip("/"), 

71 client_key=settings.growthbook_client_key, 

72 cache_ttl=settings.growthbook_features_ttl_seconds, 

73 decryption_key=settings.growthbook_decryption_key, 

74 ) 

75 self._client = GrowthBookClient(options) 

76 success = await self._client.initialize() 

77 if not success: 

78 logger.error("GrowthBook client initialize() returned false") 

79 self._client = None 

80 return 

81 self._initialized = True 

82 logger.info( 

83 "GrowthBook client initialized (host=%s, ttl=%ss)", 

84 settings.growthbook_api_host, 

85 settings.growthbook_features_ttl_seconds, 

86 ) 

87 except ImportError: 

88 logger.error("growthbook package not installed — pip install growthbook") 

89 except Exception as exc: 

90 logger.error("GrowthBook client failed to start: %s", exc) 

91 

92 async def stop(self) -> None: 

93 if self._client is not None: 

94 try: 

95 await self._client.close() 

96 except Exception as exc: 

97 logger.warning("GrowthBook client close error: %s", exc) 

98 self._client = None 

99 self._initialized = False 

100 

101 async def eval_boolean( 

102 self, 

103 feature_key: str, 

104 ctx: TargetingContext, 

105 ) -> Optional[bool]: 

106 settings = get_growthbook_settings() 

107 

108 if not settings.growthbook_enabled or self._client is None: 

109 return None 

110 

111 if not self.has_feature(feature_key): 

112 return None 

113 

114 try: 

115 from growthbook import UserContext 

116 

117 user = UserContext(attributes=ctx.to_growthbook_attributes()) 

118 result = await self._client.is_on(feature_key, user) 

119 return bool(result) 

120 except Exception as exc: 

121 logger.warning( 

122 "GrowthBook eval failed for %s: %s — using fallback", 

123 feature_key, 

124 exc, 

125 ) 

126 if settings.growthbook_fail_open: 

127 return registry_default_enabled(feature_key, ctx.plan) 

128 return None 

129 

130 

131growthbook_client = GrowthBookClientWrapper()