Skip to content
69 changes: 54 additions & 15 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
from posthog.request import (
DEFAULT_HOST,
APIError,
QuotaLimitError,
RequestsConnectionError,
RequestsTimeout,
batch_post,
determine_server_host,
flags,
Expand All @@ -53,6 +56,7 @@
)
from posthog.types import (
FeatureFlag,
FeatureFlagError,
FeatureFlagResult,
FlagMetadata,
FlagsAndPayloads,
Expand Down Expand Up @@ -1506,6 +1510,19 @@ def feature_enabled(
return None
return bool(response)

def _get_stale_flag_fallback(
self, distinct_id: ID_TYPES, key: str
) -> Optional[FeatureFlagResult]:
"""Returns a stale cached flag value if available, otherwise None."""
if self.flag_cache:
stale_result = self.flag_cache.get_stale_cached_flag(distinct_id, key)
if stale_result:
self.log.info(
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
)
return stale_result
return None

def _get_feature_flag_result(
self,
key: str,
Expand Down Expand Up @@ -1539,6 +1556,7 @@ def _get_feature_flag_result(
flag_details = None
request_id = None
evaluated_at = None
feature_flag_error: Optional[str] = None

flag_value = self._locally_evaluate_flag(
key, distinct_id, groups, person_properties, group_properties
Expand All @@ -1563,7 +1581,7 @@ def _get_feature_flag_result(
)
elif not only_evaluate_locally:
try:
flag_details, request_id, evaluated_at = (
flag_details, request_id, evaluated_at, errors_while_computing = (
self._get_feature_flag_details_from_server(
key,
distinct_id,
Expand All @@ -1573,6 +1591,14 @@ def _get_feature_flag_result(
disable_geoip,
)
)
errors = []
if errors_while_computing:
errors.append(FeatureFlagError.ERRORS_WHILE_COMPUTING)
if flag_details is None:
errors.append(FeatureFlagError.FLAG_MISSING)
if errors:
feature_flag_error = ",".join(errors)

flag_result = FeatureFlagResult.from_flag_details(
flag_details, override_match_value
)
Expand All @@ -1586,19 +1612,26 @@ def _get_feature_flag_result(
self.log.debug(
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
)
except QuotaLimitError as e:
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
feature_flag_error = FeatureFlagError.QUOTA_LIMITED
flag_result = self._get_stale_flag_fallback(distinct_id, key)
except RequestsTimeout as e:
self.log.warning(f"[FEATURE FLAGS] Request timed out: {e}")
feature_flag_error = FeatureFlagError.TIMEOUT
flag_result = self._get_stale_flag_fallback(distinct_id, key)
except RequestsConnectionError as e:
self.log.warning(f"[FEATURE FLAGS] Connection error: {e}")
feature_flag_error = FeatureFlagError.CONNECTION_ERROR
flag_result = self._get_stale_flag_fallback(distinct_id, key)
except APIError as e:
self.log.warning(f"[FEATURE FLAGS] API error: {e}")
feature_flag_error = FeatureFlagError.api_error(e.status)
flag_result = self._get_stale_flag_fallback(distinct_id, key)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")

# Fallback to cached value if remote evaluation fails
if self.flag_cache:
stale_result = self.flag_cache.get_stale_cached_flag(
distinct_id, key
)
if stale_result:
self.log.info(
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
)
flag_result = stale_result
feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
flag_result = self._get_stale_flag_fallback(distinct_id, key)

if send_feature_flag_events:
self._capture_feature_flag_called(
Expand All @@ -1612,6 +1645,7 @@ def _get_feature_flag_result(
request_id,
evaluated_at,
flag_details,
feature_flag_error,
)

return flag_result
Expand Down Expand Up @@ -1814,9 +1848,10 @@ def _get_feature_flag_details_from_server(
person_properties: dict[str, str],
group_properties: dict[str, str],
disable_geoip: Optional[bool],
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int]]:
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
"""
Calls /flags and returns the flag details, request id, and evaluated at timestamp
Calls /flags and returns the flag details, request id, evaluated at timestamp,
and whether there were errors while computing flags.
"""
resp_data = self.get_flags_decision(
distinct_id,
Expand All @@ -1828,9 +1863,10 @@ def _get_feature_flag_details_from_server(
)
request_id = resp_data.get("requestId")
evaluated_at = resp_data.get("evaluatedAt")
errors_while_computing = resp_data.get("errorsWhileComputingFlags", False)
flags = resp_data.get("flags")
flag_details = flags.get(key) if flags else None
return flag_details, request_id, evaluated_at
return flag_details, request_id, evaluated_at, errors_while_computing

def _capture_feature_flag_called(
self,
Expand All @@ -1844,6 +1880,7 @@ def _capture_feature_flag_called(
request_id: Optional[str],
evaluated_at: Optional[int],
flag_details: Optional[FeatureFlag],
feature_flag_error: Optional[str] = None,
):
feature_flag_reported_key = (
f"{key}_{'::null::' if response is None else str(response)}"
Expand Down Expand Up @@ -1878,6 +1915,8 @@ def _capture_feature_flag_called(
)
if flag_details.metadata.id:
properties["$feature_flag_id"] = flag_details.metadata.id
if feature_flag_error:
properties["$feature_flag_error"] = feature_flag_error

self.capture(
"$feature_flag_called",
Expand Down
6 changes: 6 additions & 0 deletions posthog/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,12 @@ class QuotaLimitError(APIError):
pass


# Re-export requests exceptions for use in client.py
# This keeps all requests library imports centralized in this module
RequestsTimeout = requests.exceptions.Timeout
RequestsConnectionError = requests.exceptions.ConnectionError


class DatetimeSerializer(json.JSONEncoder):
def default(self, obj: Any):
if isinstance(obj, (date, datetime)):
Expand Down
Loading
Loading