Skip to content

Commit d18eddc

Browse files
committed
Add specific error types for feature flag request failures
Replace generic "request_error" with specific error types: - quota_limited: Rate/quota limit exceeded (QuotaLimitError) - timeout: Request timed out (requests.exceptions.Timeout) - connection_error: Network connectivity issue (requests.exceptions.ConnectionError) - api_error_{status}: Server error with HTTP status code (APIError) - unknown_error: Unexpected exceptions
1 parent 0b2b675 commit d18eddc

File tree

2 files changed

+119
-9
lines changed

2 files changed

+119
-9
lines changed

posthog/client.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@
3636
from posthog.request import (
3737
DEFAULT_HOST,
3838
APIError,
39+
QuotaLimitError,
3940
batch_post,
4041
determine_server_host,
4142
flags,
4243
get,
4344
remote_config,
4445
)
46+
import requests.exceptions
4547
from posthog.contexts import (
4648
_get_current_context,
4749
get_context_distinct_id,
@@ -1592,10 +1594,23 @@ def _get_feature_flag_result(
15921594
self.log.debug(
15931595
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
15941596
)
1597+
except QuotaLimitError as e:
1598+
self.log.exception(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
1599+
feature_flag_error = "quota_limited"
1600+
except requests.exceptions.Timeout as e:
1601+
self.log.exception(f"[FEATURE FLAGS] Request timed out: {e}")
1602+
feature_flag_error = "timeout"
1603+
except requests.exceptions.ConnectionError as e:
1604+
self.log.exception(f"[FEATURE FLAGS] Connection error: {e}")
1605+
feature_flag_error = "connection_error"
1606+
except APIError as e:
1607+
self.log.exception(f"[FEATURE FLAGS] API error: {e}")
1608+
feature_flag_error = f"api_error_{e.status}"
15951609
except Exception as e:
15961610
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
1597-
feature_flag_error = "request_error"
1611+
feature_flag_error = "unknown_error"
15981612

1613+
if feature_flag_error:
15991614
# Fallback to cached value if remote evaluation fails
16001615
if self.flag_cache:
16011616
stale_result = self.flag_cache.get_stale_cached_flag(

posthog/test/test_feature_flag_result.py

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -533,14 +533,109 @@ def test_get_feature_flag_result_flag_not_in_response(
533533

534534
@mock.patch("posthog.client.flags")
535535
@mock.patch.object(Client, "capture")
536-
def test_get_feature_flag_result_request_error(self, patch_capture, patch_flags):
537-
"""Test that when the /flags call throws an exception, we capture it in the event.
536+
def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags):
537+
"""Test that unexpected exceptions are captured as unknown_error."""
538+
patch_flags.side_effect = Exception("Unexpected error")
538539

539-
This simulates what likely happened in production: the remote evaluation failed
540-
(network error, timeout, etc.), so request_id is never set, and we capture the
541-
$feature_flag_called event with a null response, no request_id, and an error.
542-
"""
543-
patch_flags.side_effect = Exception("Network timeout")
540+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
541+
542+
self.assertIsNone(flag_result)
543+
patch_capture.assert_called_with(
544+
"$feature_flag_called",
545+
distinct_id="some-distinct-id",
546+
properties={
547+
"$feature_flag": "my-flag",
548+
"$feature_flag_response": None,
549+
"locally_evaluated": False,
550+
"$feature/my-flag": None,
551+
"$feature_flag_error": "unknown_error",
552+
},
553+
groups={},
554+
disable_geoip=None,
555+
)
556+
557+
@mock.patch("posthog.client.flags")
558+
@mock.patch.object(Client, "capture")
559+
def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags):
560+
"""Test that timeout errors are captured specifically."""
561+
import requests.exceptions
562+
563+
patch_flags.side_effect = requests.exceptions.Timeout("Request timed out")
564+
565+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
566+
567+
self.assertIsNone(flag_result)
568+
patch_capture.assert_called_with(
569+
"$feature_flag_called",
570+
distinct_id="some-distinct-id",
571+
properties={
572+
"$feature_flag": "my-flag",
573+
"$feature_flag_response": None,
574+
"locally_evaluated": False,
575+
"$feature/my-flag": None,
576+
"$feature_flag_error": "timeout",
577+
},
578+
groups={},
579+
disable_geoip=None,
580+
)
581+
582+
@mock.patch("posthog.client.flags")
583+
@mock.patch.object(Client, "capture")
584+
def test_get_feature_flag_result_connection_error(self, patch_capture, patch_flags):
585+
"""Test that connection errors are captured specifically."""
586+
import requests.exceptions
587+
588+
patch_flags.side_effect = requests.exceptions.ConnectionError("Connection refused")
589+
590+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
591+
592+
self.assertIsNone(flag_result)
593+
patch_capture.assert_called_with(
594+
"$feature_flag_called",
595+
distinct_id="some-distinct-id",
596+
properties={
597+
"$feature_flag": "my-flag",
598+
"$feature_flag_response": None,
599+
"locally_evaluated": False,
600+
"$feature/my-flag": None,
601+
"$feature_flag_error": "connection_error",
602+
},
603+
groups={},
604+
disable_geoip=None,
605+
)
606+
607+
@mock.patch("posthog.client.flags")
608+
@mock.patch.object(Client, "capture")
609+
def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags):
610+
"""Test that API errors include the status code."""
611+
from posthog.request import APIError
612+
613+
patch_flags.side_effect = APIError(500, "Internal server error")
614+
615+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
616+
617+
self.assertIsNone(flag_result)
618+
patch_capture.assert_called_with(
619+
"$feature_flag_called",
620+
distinct_id="some-distinct-id",
621+
properties={
622+
"$feature_flag": "my-flag",
623+
"$feature_flag_response": None,
624+
"locally_evaluated": False,
625+
"$feature/my-flag": None,
626+
"$feature_flag_error": "api_error_500",
627+
},
628+
groups={},
629+
disable_geoip=None,
630+
)
631+
632+
@mock.patch("posthog.client.flags")
633+
@mock.patch.object(Client, "capture")
634+
def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags):
635+
"""Test that quota limit errors are captured specifically."""
636+
from posthog.request import QuotaLimitError
637+
638+
patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
544639

545640
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
546641

@@ -553,7 +648,7 @@ def test_get_feature_flag_result_request_error(self, patch_capture, patch_flags)
553648
"$feature_flag_response": None,
554649
"locally_evaluated": False,
555650
"$feature/my-flag": None,
556-
"$feature_flag_error": "request_error",
651+
"$feature_flag_error": "quota_limited",
557652
},
558653
groups={},
559654
disable_geoip=None,

0 commit comments

Comments
 (0)