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
« 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"""
5import asyncio
6import logging
7from typing import Any, Optional
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
13logger = logging.getLogger(__name__)
16class GrowthBookClientWrapper:
17 """Thin wrapper around growthbook-python GrowthBookClient."""
19 def __init__(self) -> None:
20 self._client: Any = None
21 self._initialized = False
22 self._lock = asyncio.Lock()
24 @property
25 def is_ready(self) -> bool:
26 return self._initialized and self._client is not None
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())
36 def has_feature(self, feature_key: str) -> bool:
37 return feature_key in self.list_feature_keys()
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)
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
66 try:
67 from growthbook import GrowthBookClient, Options
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)
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
101 async def eval_boolean(
102 self,
103 feature_key: str,
104 ctx: TargetingContext,
105 ) -> Optional[bool]:
106 settings = get_growthbook_settings()
108 if not settings.growthbook_enabled or self._client is None:
109 return None
111 if not self.has_feature(feature_key):
112 return None
114 try:
115 from growthbook import UserContext
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
131growthbook_client = GrowthBookClientWrapper()