5
5
import threading
6
6
from datetime import datetime , timedelta
7
7
from typing import Dict , Any , Callable , Optional
8
- from concurrent .futures import Future , ThreadPoolExecutor
9
8
from .types import ExperimentationFlag , ExperimentationFlags , SelectedVariant , LocalFlagsConfig , Rollout
10
9
from .utils import REQUEST_HEADERS , normalized_hash , prepare_common_query_params , EXPOSURE_EVENT
11
10
@@ -16,13 +15,20 @@ class LocalFeatureFlagsProvider:
16
15
FLAGS_DEFINITIONS_URL_PATH = "/flags/definitions"
17
16
18
17
def __init__ (self , token : str , config : LocalFlagsConfig , version : str , tracker : Callable ) -> None :
18
+ """
19
+ Initializes the LocalFeatureFlagsProvider
20
+ :param str token: your project's Mixpanel token
21
+ :param LocalFlagsConfig config: configuration options for the local feature flags provider
22
+ :param str version: the version of the Mixpanel library being used, just for tracking
23
+ :param str tracker: A function used to track flags exposure events to mixpanel
24
+ """
19
25
self ._token : str = token
20
26
self ._config : LocalFlagsConfig = config
21
27
self ._version = version
22
28
self ._tracker : Callable = tracker
23
- self ._executor : ThreadPoolExecutor = config .custom_executor or ThreadPoolExecutor (max_workers = 5 )
24
29
25
30
self ._flag_definitions : Dict [str , ExperimentationFlag ] = dict ()
31
+ self ._are_flags_ready = False
26
32
27
33
httpx_client_parameters = {
28
34
"base_url" : f"https://{ config .api_host } " ,
@@ -37,29 +43,41 @@ def __init__(self, token: str, config: LocalFlagsConfig, version: str, tracker:
37
43
self ._sync_client : httpx .Client = httpx .Client (** httpx_client_parameters )
38
44
39
45
self ._async_polling_task : Optional [asyncio .Task ] = None
40
- self ._sync_polling_task : Optional [Future ] = None
46
+ self ._sync_polling_task : Optional [threading . Thread ] = None
41
47
42
48
self ._sync_stop_event = threading .Event ()
43
49
44
50
def start_polling_for_definitions (self ):
51
+ """
52
+ Fetches flag definitions for the current project.
53
+ If configured by the caller, starts a background thread to poll for updates at regular intervals, if one does not already exist.
54
+ """
45
55
self ._fetch_flag_definitions ()
46
56
47
57
if self ._config .enable_polling :
48
58
if not self ._sync_polling_task and not self ._async_polling_task :
49
59
self ._sync_stop_event .clear ()
50
- self ._sync_polling_task = self ._executor .submit (self ._start_continuous_polling )
60
+ self ._sync_polling_task = threading .Thread (target = self ._start_continuous_polling , daemon = True )
61
+ self ._sync_polling_task .start ()
51
62
else :
52
- logging .error ("A polling task is already running" )
63
+ logging .warning ("A polling task is already running" )
53
64
54
65
def stop_polling_for_definitions (self ):
66
+ """
67
+ If there exists a reference to a background thread polling for flag definition updates, signal it to stop and clear the reference.
68
+ Once stopped, the polling thread cannot be restarted.
69
+ """
55
70
if self ._sync_polling_task :
56
71
self ._sync_stop_event .set ()
57
- self ._sync_polling_task .cancel ()
58
72
self ._sync_polling_task = None
59
73
else :
60
74
logging .info ("There is no polling task to cancel." )
61
75
62
76
async def astart_polling_for_definitions (self ):
77
+ """
78
+ Fetches flag definitions for the current project.
79
+ If configured by the caller, starts an async task on the event loop to poll for updates at regular intervals, if one does not already exist.
80
+ """
63
81
await self ._afetch_flag_definitions ()
64
82
65
83
if self ._config .enable_polling :
@@ -69,6 +87,9 @@ async def astart_polling_for_definitions(self):
69
87
logging .error ("A polling task is already running" )
70
88
71
89
async def astop_polling_for_definitions (self ):
90
+ """
91
+ If there exists an async task to poll for flag definition updates, cancel the task and clear the reference to it.
92
+ """
72
93
if self ._async_polling_task :
73
94
self ._async_polling_task .cancel ()
74
95
self ._async_polling_task = None
@@ -94,20 +115,39 @@ def _start_continuous_polling(self):
94
115
95
116
def are_flags_ready (self ) -> bool :
96
117
"""
97
- Check if flag definitions have been loaded and are ready for use.
98
- :return: True if flag definitions are populated, False otherwise.
118
+ Check if the call to fetch flag definitions has been made successfully.
99
119
"""
100
- return bool ( self ._flag_definitions )
120
+ return self ._are_flags_ready
101
121
102
122
def get_variant_value (self , flag_key : str , fallback_value : Any , context : Dict [str , Any ]) -> Any :
123
+ """
124
+ Get the value of a feature flag variant.
125
+
126
+ :param str flag_key: The key of the feature flag to evaluate
127
+ :param Any fallback_value: The default value to return if the flag is not found or evaluation fails
128
+ :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation
129
+ """
103
130
variant = self .get_variant (flag_key , SelectedVariant (variant_value = fallback_value ), context )
104
131
return variant .variant_value
105
132
106
133
def is_enabled (self , flag_key : str , context : Dict [str , Any ]) -> bool :
134
+ """
135
+ Check if a feature flag is enabled for the given context.
136
+
137
+ :param str flag_key: The key of the feature flag to check
138
+ :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation
139
+ """
107
140
variant_value = self .get_variant_value (flag_key , False , context )
108
141
return bool (variant_value )
109
142
110
143
def get_variant (self , flag_key : str , fallback_value : SelectedVariant , context : Dict [str , Any ]) -> SelectedVariant :
144
+ """
145
+ Gets the selected variant for a feature flag
146
+
147
+ :param str flag_key: The key of the feature flag to evaluate
148
+ :param SelectedVariant fallback_value: The default variant to return if evaluation fails
149
+ :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation
150
+ """
111
151
start_time = time .perf_counter ()
112
152
flag_definition = self ._flag_definitions .get (flag_key )
113
153
@@ -125,7 +165,7 @@ def get_variant(self, flag_key: str, fallback_value: SelectedVariant, context: D
125
165
if rollout := self ._get_assigned_rollout (flag_definition , context_value , context ):
126
166
variant = self ._get_assigned_variant (flag_definition , context_value , flag_key , rollout )
127
167
end_time = time .perf_counter ()
128
- self .track_exposure (flag_key , variant , end_time - start_time , context )
168
+ self ._track_exposure (flag_key , variant , end_time - start_time , context )
129
169
return variant
130
170
131
171
logger .info (f"{ flag_definition .context } context { context_value } not eligible for any rollout for flag: { flag_key } " )
@@ -237,10 +277,11 @@ def _handle_response(self, response: httpx.Response, start_time: datetime, end_t
237
277
logger .exception ("Failed to parse flag definitions" )
238
278
239
279
self ._flag_definitions = flags
280
+ self ._are_flags_ready = True
240
281
logger .info (f"Successfully fetched { len (self ._flag_definitions )} flag definitions" )
241
282
242
283
243
- def track_exposure (self , flag_key : str , variant : SelectedVariant , latency_in_seconds : float , context : Dict [str , Any ]):
284
+ def _track_exposure (self , flag_key : str , variant : SelectedVariant , latency_in_seconds : float , context : Dict [str , Any ]):
244
285
if distinct_id := context .get ("distinct_id" ):
245
286
properties = {
246
287
'Experiment name' : flag_key ,
@@ -249,7 +290,8 @@ def track_exposure(self, flag_key: str, variant: SelectedVariant, latency_in_sec
249
290
"Flag evaluation mode" : "local" ,
250
291
"Variant fetch latency (ms)" : latency_in_seconds * 1000
251
292
}
252
- self ._executor .submit (self ._tracker , distinct_id , EXPOSURE_EVENT , properties )
293
+
294
+ self ._tracker (distinct_id , EXPOSURE_EVENT , properties )
253
295
else :
254
296
logging .error ("Cannot track exposure event without a distinct_id in the context" )
255
297
0 commit comments