50
50
to_values ,
51
51
)
52
52
from posthog .utils import (
53
+ FlagCache ,
54
+ RedisFlagCache ,
53
55
SizeLimitedDict ,
54
56
clean ,
55
57
guess_timezone ,
@@ -95,7 +97,30 @@ def add_context_tags(properties):
95
97
96
98
97
99
class Client (object ):
98
- """Create a new PostHog client."""
100
+ """Create a new PostHog client.
101
+
102
+ Examples:
103
+ Basic usage:
104
+ >>> client = Client("your-api-key")
105
+
106
+ With memory-based feature flag fallback cache:
107
+ >>> client = Client(
108
+ ... "your-api-key",
109
+ ... flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
110
+ ... )
111
+
112
+ With Redis fallback cache for high-scale applications:
113
+ >>> client = Client(
114
+ ... "your-api-key",
115
+ ... flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
116
+ ... )
117
+
118
+ With Redis authentication:
119
+ >>> client = Client(
120
+ ... "your-api-key",
121
+ ... flag_fallback_cache_url="redis://username:password@localhost:6379/0/?ttl=300"
122
+ ... )
123
+ """
99
124
100
125
log = logging .getLogger ("posthog" )
101
126
@@ -126,6 +151,7 @@ def __init__(
126
151
project_root = None ,
127
152
privacy_mode = False ,
128
153
before_send = None ,
154
+ flag_fallback_cache_url = None ,
129
155
):
130
156
self .queue = queue .Queue (max_queue_size )
131
157
@@ -151,6 +177,8 @@ def __init__(
151
177
)
152
178
self .poller = None
153
179
self .distinct_ids_feature_flags_reported = SizeLimitedDict (MAX_DICT_SIZE , set )
180
+ self .flag_cache = self ._initialize_flag_cache (flag_fallback_cache_url )
181
+ self .flag_definition_version = 0
154
182
self .disabled = disabled
155
183
self .disable_geoip = disable_geoip
156
184
self .historical_migration = historical_migration
@@ -707,6 +735,9 @@ def shutdown(self):
707
735
708
736
def _load_feature_flags (self ):
709
737
try :
738
+ # Store old flags to detect changes
739
+ old_flags_by_key : dict [str , dict ] = self .feature_flags_by_key or {}
740
+
710
741
response = get (
711
742
self .personal_api_key ,
712
743
f"/api/feature_flag/local_evaluation/?token={ self .api_key } &send_cohorts" ,
@@ -718,6 +749,14 @@ def _load_feature_flags(self):
718
749
self .group_type_mapping = response ["group_type_mapping" ] or {}
719
750
self .cohorts = response ["cohorts" ] or {}
720
751
752
+ # Check if flag definitions changed and update version
753
+ if self .flag_cache and old_flags_by_key != (
754
+ self .feature_flags_by_key or {}
755
+ ):
756
+ old_version = self .flag_definition_version
757
+ self .flag_definition_version += 1
758
+ self .flag_cache .invalidate_version (old_version )
759
+
721
760
except APIError as e :
722
761
if e .status == 401 :
723
762
self .log .error (
@@ -739,6 +778,10 @@ def _load_feature_flags(self):
739
778
self .group_type_mapping = {}
740
779
self .cohorts = {}
741
780
781
+ # Clear flag cache when quota limited
782
+ if self .flag_cache :
783
+ self .flag_cache .clear ()
784
+
742
785
if self .debug :
743
786
raise APIError (
744
787
status = 402 ,
@@ -889,6 +932,12 @@ def _get_feature_flag_result(
889
932
flag_result = FeatureFlagResult .from_value_and_payload (
890
933
key , lookup_match_value , payload
891
934
)
935
+
936
+ # Cache successful local evaluation
937
+ if self .flag_cache and flag_result :
938
+ self .flag_cache .set_cached_flag (
939
+ distinct_id , key , flag_result , self .flag_definition_version
940
+ )
892
941
elif not only_evaluate_locally :
893
942
try :
894
943
flag_details , request_id = self ._get_feature_flag_details_from_decide (
@@ -902,12 +951,30 @@ def _get_feature_flag_result(
902
951
flag_result = FeatureFlagResult .from_flag_details (
903
952
flag_details , override_match_value
904
953
)
954
+
955
+ # Cache successful remote evaluation
956
+ if self .flag_cache and flag_result :
957
+ self .flag_cache .set_cached_flag (
958
+ distinct_id , key , flag_result , self .flag_definition_version
959
+ )
960
+
905
961
self .log .debug (
906
962
f"Successfully computed flag remotely: #{ key } -> #{ flag_result } "
907
963
)
908
964
except Exception as e :
909
965
self .log .exception (f"[FEATURE FLAGS] Unable to get flag remotely: { e } " )
910
966
967
+ # Fallback to cached value if remote evaluation fails
968
+ if self .flag_cache :
969
+ stale_result = self .flag_cache .get_stale_cached_flag (
970
+ distinct_id , key
971
+ )
972
+ if stale_result :
973
+ self .log .info (
974
+ f"[FEATURE FLAGS] Using stale cached value for flag { key } "
975
+ )
976
+ flag_result = stale_result
977
+
911
978
if send_feature_flag_events :
912
979
self ._capture_feature_flag_called (
913
980
distinct_id ,
@@ -1278,6 +1345,99 @@ def _get_all_flags_and_payloads_locally(
1278
1345
"featureFlagPayloads" : payloads ,
1279
1346
}, fallback_to_decide
1280
1347
1348
+ def _initialize_flag_cache (self , cache_url ):
1349
+ """Initialize feature flag cache for graceful degradation during service outages.
1350
+
1351
+ When enabled, the cache stores flag evaluation results and serves them as fallback
1352
+ when the PostHog API is unavailable. This ensures your application continues to
1353
+ receive flag values even during outages.
1354
+
1355
+ Args:
1356
+ cache_url: Cache configuration URL. Examples:
1357
+ - None: Disable caching
1358
+ - "memory://local/?ttl=300&size=10000": Memory cache with TTL and size
1359
+ - "redis://localhost:6379/0/?ttl=300": Redis cache with TTL
1360
+ - "redis://username:password@host:port/?ttl=300": Redis with auth
1361
+
1362
+ Example usage:
1363
+ # Memory cache
1364
+ client = Client(
1365
+ "your-api-key",
1366
+ flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
1367
+ )
1368
+
1369
+ # Redis cache
1370
+ client = Client(
1371
+ "your-api-key",
1372
+ flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
1373
+ )
1374
+
1375
+ # Normal evaluation - cache is populated
1376
+ flag_value = client.get_feature_flag("my-flag", "user123")
1377
+
1378
+ # During API outage - returns cached value instead of None
1379
+ flag_value = client.get_feature_flag("my-flag", "user123") # Uses cache
1380
+ """
1381
+ if not cache_url :
1382
+ return None
1383
+
1384
+ try :
1385
+ from urllib .parse import urlparse , parse_qs
1386
+ except ImportError :
1387
+ from urlparse import urlparse , parse_qs
1388
+
1389
+ try :
1390
+ parsed = urlparse (cache_url )
1391
+ scheme = parsed .scheme .lower ()
1392
+ query_params = parse_qs (parsed .query )
1393
+ ttl = int (query_params .get ("ttl" , [300 ])[0 ])
1394
+
1395
+ if scheme == "memory" :
1396
+ size = int (query_params .get ("size" , [10000 ])[0 ])
1397
+ return FlagCache (size , ttl )
1398
+
1399
+ elif scheme == "redis" :
1400
+ try :
1401
+ # Not worth importing redis if we're not using it
1402
+ import redis
1403
+
1404
+ redis_url = f"{ parsed .scheme } ://"
1405
+ if parsed .username or parsed .password :
1406
+ redis_url += f"{ parsed .username or '' } :{ parsed .password or '' } @"
1407
+ redis_url += (
1408
+ f"{ parsed .hostname or 'localhost' } :{ parsed .port or 6379 } "
1409
+ )
1410
+ if parsed .path :
1411
+ redis_url += parsed .path
1412
+
1413
+ client = redis .from_url (redis_url )
1414
+
1415
+ # Test connection before using it
1416
+ client .ping ()
1417
+
1418
+ return RedisFlagCache (client , default_ttl = ttl )
1419
+
1420
+ except ImportError :
1421
+ self .log .warning (
1422
+ "[FEATURE FLAGS] Redis not available, flag caching disabled"
1423
+ )
1424
+ return None
1425
+ except Exception as e :
1426
+ self .log .warning (
1427
+ f"[FEATURE FLAGS] Redis connection failed: { e } , flag caching disabled"
1428
+ )
1429
+ return None
1430
+ else :
1431
+ raise ValueError (
1432
+ f"Unknown cache URL scheme: { scheme } . Supported schemes: memory, redis"
1433
+ )
1434
+
1435
+ except Exception as e :
1436
+ self .log .warning (
1437
+ f"[FEATURE FLAGS] Failed to parse cache URL '{ cache_url } ': { e } "
1438
+ )
1439
+ return None
1440
+
1281
1441
def feature_flag_definitions (self ):
1282
1442
return self .feature_flags
1283
1443
0 commit comments