posthoganalytics 7.0.1__py3-none-any.whl → 7.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- posthoganalytics/__init__.py +10 -0
- posthoganalytics/ai/gemini/__init__.py +3 -0
- posthoganalytics/ai/gemini/gemini.py +1 -1
- posthoganalytics/ai/gemini/gemini_async.py +423 -0
- posthoganalytics/ai/gemini/gemini_converter.py +87 -21
- posthoganalytics/ai/openai/openai.py +27 -2
- posthoganalytics/ai/openai/openai_async.py +27 -2
- posthoganalytics/ai/openai/openai_converter.py +6 -0
- posthoganalytics/ai/sanitization.py +27 -5
- posthoganalytics/ai/utils.py +2 -2
- posthoganalytics/client.py +224 -58
- posthoganalytics/exception_utils.py +49 -4
- posthoganalytics/flag_definition_cache.py +127 -0
- posthoganalytics/request.py +203 -23
- posthoganalytics/test/test_client.py +207 -22
- posthoganalytics/test/test_exception_capture.py +45 -1
- posthoganalytics/test/test_feature_flag_result.py +441 -2
- posthoganalytics/test/test_feature_flags.py +166 -73
- posthoganalytics/test/test_flag_definition_cache.py +612 -0
- posthoganalytics/test/test_request.py +536 -0
- posthoganalytics/test/test_utils.py +4 -1
- posthoganalytics/types.py +40 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/METADATA +2 -1
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/RECORD +28 -25
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/WHEEL +0 -0
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/top_level.txt +0 -0
|
@@ -59,8 +59,29 @@ def test_code_variables_capture(tmpdir):
|
|
|
59
59
|
my_number = 42
|
|
60
60
|
my_bool = True
|
|
61
61
|
my_dict = {"name": "test", "value": 123}
|
|
62
|
+
my_sensitive_dict = {
|
|
63
|
+
"safe_key": "safe_value",
|
|
64
|
+
"password": "secret123", # key matches pattern -> should be masked
|
|
65
|
+
"other_key": "contains_password_here", # value matches pattern -> should be masked
|
|
66
|
+
}
|
|
67
|
+
my_nested_dict = {
|
|
68
|
+
"level1": {
|
|
69
|
+
"level2": {
|
|
70
|
+
"api_key": "nested_secret", # deeply nested key matches
|
|
71
|
+
"data": "contains_token_here", # deeply nested value matches
|
|
72
|
+
"safe": "visible",
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
my_list = ["safe_item", "has_password_inside", "another_safe"]
|
|
77
|
+
my_tuple = ("tuple_safe", "secret_in_value", "tuple_also_safe")
|
|
78
|
+
my_list_of_dicts = [
|
|
79
|
+
{"id": 1, "password": "list_dict_secret"},
|
|
80
|
+
{"id": 2, "value": "safe_value"},
|
|
81
|
+
]
|
|
62
82
|
my_obj = UnserializableObject()
|
|
63
|
-
my_password = "secret123" # Should be masked by default
|
|
83
|
+
my_password = "secret123" # Should be masked by default (name matches)
|
|
84
|
+
my_innocent_var = "contains_password_here" # Should be masked by default (value matches)
|
|
64
85
|
__should_be_ignored = "hidden" # Should be ignored by default
|
|
65
86
|
|
|
66
87
|
1/0 # Trigger exception
|
|
@@ -96,8 +117,31 @@ def test_code_variables_capture(tmpdir):
|
|
|
96
117
|
assert b"'my_number': 42" in output
|
|
97
118
|
assert b"'my_bool': 'True'" in output
|
|
98
119
|
assert b'"my_dict": "{\\"name\\": \\"test\\", \\"value\\": 123}"' in output
|
|
120
|
+
assert (
|
|
121
|
+
b'{\\"safe_key\\": \\"safe_value\\", \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"other_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}'
|
|
122
|
+
in output
|
|
123
|
+
)
|
|
124
|
+
assert (
|
|
125
|
+
b'{\\"level1\\": {\\"level2\\": {\\"api_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
|
|
126
|
+
in output
|
|
127
|
+
)
|
|
128
|
+
assert (
|
|
129
|
+
b'[\\"safe_item\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
|
|
130
|
+
in output
|
|
131
|
+
)
|
|
132
|
+
assert (
|
|
133
|
+
b'[\\"tuple_safe\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
|
|
134
|
+
in output
|
|
135
|
+
)
|
|
136
|
+
assert (
|
|
137
|
+
b'[{\\"id\\": 1, \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}, {\\"id\\": 2, \\"value\\": \\"safe_value\\"}]'
|
|
138
|
+
in output
|
|
139
|
+
)
|
|
99
140
|
assert b"<__main__.UnserializableObject object at" in output
|
|
100
141
|
assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
|
142
|
+
assert (
|
|
143
|
+
b"'my_innocent_var': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
|
144
|
+
)
|
|
101
145
|
assert b"'__should_be_ignored':" not in output
|
|
102
146
|
|
|
103
147
|
# Variables from intermediate_function frame
|
|
@@ -4,7 +4,13 @@ import mock
|
|
|
4
4
|
|
|
5
5
|
from posthoganalytics.client import Client
|
|
6
6
|
from posthoganalytics.test.test_utils import FAKE_TEST_API_KEY
|
|
7
|
-
from posthoganalytics.types import
|
|
7
|
+
from posthoganalytics.types import (
|
|
8
|
+
FeatureFlag,
|
|
9
|
+
FeatureFlagError,
|
|
10
|
+
FeatureFlagResult,
|
|
11
|
+
FlagMetadata,
|
|
12
|
+
FlagReason,
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
class TestFeatureFlagResult(unittest.TestCase):
|
|
@@ -189,7 +195,6 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
|
|
189
195
|
|
|
190
196
|
def set_fail(self, e, batch):
|
|
191
197
|
"""Mark the failure handler"""
|
|
192
|
-
print("FAIL", e, batch) # noqa: T201
|
|
193
198
|
self.failed = True
|
|
194
199
|
|
|
195
200
|
def setUp(self):
|
|
@@ -241,6 +246,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
|
|
241
246
|
groups={},
|
|
242
247
|
disable_geoip=None,
|
|
243
248
|
)
|
|
249
|
+
# Verify error property is NOT present on successful evaluation
|
|
250
|
+
captured_properties = patch_capture.call_args[1]["properties"]
|
|
251
|
+
self.assertNotIn("$feature_flag_error", captured_properties)
|
|
244
252
|
|
|
245
253
|
@mock.patch.object(Client, "capture")
|
|
246
254
|
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
|
|
@@ -295,6 +303,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
|
|
295
303
|
groups={},
|
|
296
304
|
disable_geoip=None,
|
|
297
305
|
)
|
|
306
|
+
# Verify error property is NOT present on successful evaluation
|
|
307
|
+
captured_properties = patch_capture.call_args[1]["properties"]
|
|
308
|
+
self.assertNotIn("$feature_flag_error", captured_properties)
|
|
298
309
|
|
|
299
310
|
another_flag_result = self.client.get_feature_flag_result(
|
|
300
311
|
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
|
|
@@ -360,6 +371,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
|
|
360
371
|
groups={},
|
|
361
372
|
disable_geoip=None,
|
|
362
373
|
)
|
|
374
|
+
# Verify error property is NOT present on successful evaluation
|
|
375
|
+
captured_properties = patch_capture.call_args[1]["properties"]
|
|
376
|
+
self.assertNotIn("$feature_flag_error", captured_properties)
|
|
363
377
|
|
|
364
378
|
@mock.patch("posthog.client.flags")
|
|
365
379
|
@mock.patch.object(Client, "capture")
|
|
@@ -403,6 +417,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
|
|
403
417
|
groups={},
|
|
404
418
|
disable_geoip=None,
|
|
405
419
|
)
|
|
420
|
+
# Verify error property is NOT present on successful evaluation
|
|
421
|
+
captured_properties = patch_capture.call_args[1]["properties"]
|
|
422
|
+
self.assertNotIn("$feature_flag_error", captured_properties)
|
|
406
423
|
|
|
407
424
|
@mock.patch("posthog.client.flags")
|
|
408
425
|
@mock.patch.object(Client, "capture")
|
|
@@ -438,6 +455,428 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
|
|
438
455
|
"$feature_flag_response": None,
|
|
439
456
|
"locally_evaluated": False,
|
|
440
457
|
"$feature/no-person-flag": None,
|
|
458
|
+
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
|
|
459
|
+
},
|
|
460
|
+
groups={},
|
|
461
|
+
disable_geoip=None,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
@mock.patch("posthog.client.flags")
|
|
465
|
+
@mock.patch.object(Client, "capture")
|
|
466
|
+
def test_get_feature_flag_result_with_errors_while_computing_flags(
|
|
467
|
+
self, patch_capture, patch_flags
|
|
468
|
+
):
|
|
469
|
+
"""Test that errors_while_computing_flags is included in the $feature_flag_called event.
|
|
470
|
+
|
|
471
|
+
When the server returns errorsWhileComputingFlags=true, it indicates that there
|
|
472
|
+
was an error computing one or more flags. We include this in the event so users
|
|
473
|
+
can identify and debug flag evaluation issues.
|
|
474
|
+
"""
|
|
475
|
+
patch_flags.return_value = {
|
|
476
|
+
"flags": {
|
|
477
|
+
"my-flag": {
|
|
478
|
+
"key": "my-flag",
|
|
479
|
+
"enabled": True,
|
|
480
|
+
"variant": None,
|
|
481
|
+
"reason": {"description": "Matched condition set 1"},
|
|
482
|
+
"metadata": {"id": 1, "version": 1, "payload": None},
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
"requestId": "test-request-id-789",
|
|
486
|
+
"errorsWhileComputingFlags": True,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
490
|
+
|
|
491
|
+
self.assertEqual(flag_result.enabled, True)
|
|
492
|
+
patch_capture.assert_called_with(
|
|
493
|
+
"$feature_flag_called",
|
|
494
|
+
distinct_id="some-distinct-id",
|
|
495
|
+
properties={
|
|
496
|
+
"$feature_flag": "my-flag",
|
|
497
|
+
"$feature_flag_response": True,
|
|
498
|
+
"locally_evaluated": False,
|
|
499
|
+
"$feature/my-flag": True,
|
|
500
|
+
"$feature_flag_request_id": "test-request-id-789",
|
|
501
|
+
"$feature_flag_reason": "Matched condition set 1",
|
|
502
|
+
"$feature_flag_id": 1,
|
|
503
|
+
"$feature_flag_version": 1,
|
|
504
|
+
"$feature_flag_error": FeatureFlagError.ERRORS_WHILE_COMPUTING,
|
|
505
|
+
},
|
|
506
|
+
groups={},
|
|
507
|
+
disable_geoip=None,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
@mock.patch("posthog.client.flags")
|
|
511
|
+
@mock.patch.object(Client, "capture")
|
|
512
|
+
def test_get_feature_flag_result_flag_not_in_response(
|
|
513
|
+
self, patch_capture, patch_flags
|
|
514
|
+
):
|
|
515
|
+
"""Test that when a flag is not in the API response, we capture flag_missing error.
|
|
516
|
+
|
|
517
|
+
This happens when a flag doesn't exist or the user doesn't match any conditions.
|
|
518
|
+
"""
|
|
519
|
+
patch_flags.return_value = {
|
|
520
|
+
"flags": {
|
|
521
|
+
"other-flag": {
|
|
522
|
+
"key": "other-flag",
|
|
523
|
+
"enabled": True,
|
|
524
|
+
"variant": None,
|
|
525
|
+
"reason": {"description": "Matched condition set 1"},
|
|
526
|
+
"metadata": {"id": 1, "version": 1, "payload": None},
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
"requestId": "test-request-id-456",
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
flag_result = self.client.get_feature_flag_result(
|
|
533
|
+
"missing-flag", "some-distinct-id"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
self.assertIsNone(flag_result)
|
|
537
|
+
patch_capture.assert_called_with(
|
|
538
|
+
"$feature_flag_called",
|
|
539
|
+
distinct_id="some-distinct-id",
|
|
540
|
+
properties={
|
|
541
|
+
"$feature_flag": "missing-flag",
|
|
542
|
+
"$feature_flag_response": None,
|
|
543
|
+
"locally_evaluated": False,
|
|
544
|
+
"$feature/missing-flag": None,
|
|
545
|
+
"$feature_flag_request_id": "test-request-id-456",
|
|
546
|
+
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
|
|
547
|
+
},
|
|
548
|
+
groups={},
|
|
549
|
+
disable_geoip=None,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
@mock.patch("posthog.client.flags")
|
|
553
|
+
@mock.patch.object(Client, "capture")
|
|
554
|
+
def test_get_feature_flag_result_errors_computing_and_flag_missing(
|
|
555
|
+
self, patch_capture, patch_flags
|
|
556
|
+
):
|
|
557
|
+
"""Test that both errors are reported when errorsWhileComputingFlags=true AND flag is missing.
|
|
558
|
+
|
|
559
|
+
This can happen when the server encounters errors computing flags AND the requested
|
|
560
|
+
flag is not in the response. Both conditions should be reported for debugging.
|
|
561
|
+
"""
|
|
562
|
+
patch_flags.return_value = {
|
|
563
|
+
"flags": {}, # Flag is missing
|
|
564
|
+
"requestId": "test-request-id-999",
|
|
565
|
+
"errorsWhileComputingFlags": True, # But errors also occurred
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
flag_result = self.client.get_feature_flag_result(
|
|
569
|
+
"missing-flag", "some-distinct-id"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
self.assertIsNone(flag_result)
|
|
573
|
+
patch_capture.assert_called_with(
|
|
574
|
+
"$feature_flag_called",
|
|
575
|
+
distinct_id="some-distinct-id",
|
|
576
|
+
properties={
|
|
577
|
+
"$feature_flag": "missing-flag",
|
|
578
|
+
"$feature_flag_response": None,
|
|
579
|
+
"locally_evaluated": False,
|
|
580
|
+
"$feature/missing-flag": None,
|
|
581
|
+
"$feature_flag_request_id": "test-request-id-999",
|
|
582
|
+
"$feature_flag_error": f"{FeatureFlagError.ERRORS_WHILE_COMPUTING},{FeatureFlagError.FLAG_MISSING}",
|
|
583
|
+
},
|
|
584
|
+
groups={},
|
|
585
|
+
disable_geoip=None,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
@mock.patch("posthog.client.flags")
|
|
589
|
+
@mock.patch.object(Client, "capture")
|
|
590
|
+
def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags):
|
|
591
|
+
"""Test that unexpected exceptions are captured as unknown_error."""
|
|
592
|
+
patch_flags.side_effect = Exception("Unexpected error")
|
|
593
|
+
|
|
594
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
595
|
+
|
|
596
|
+
self.assertIsNone(flag_result)
|
|
597
|
+
patch_capture.assert_called_with(
|
|
598
|
+
"$feature_flag_called",
|
|
599
|
+
distinct_id="some-distinct-id",
|
|
600
|
+
properties={
|
|
601
|
+
"$feature_flag": "my-flag",
|
|
602
|
+
"$feature_flag_response": None,
|
|
603
|
+
"locally_evaluated": False,
|
|
604
|
+
"$feature/my-flag": None,
|
|
605
|
+
"$feature_flag_error": FeatureFlagError.UNKNOWN_ERROR,
|
|
606
|
+
},
|
|
607
|
+
groups={},
|
|
608
|
+
disable_geoip=None,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
@mock.patch("posthog.client.flags")
|
|
612
|
+
@mock.patch.object(Client, "capture")
|
|
613
|
+
def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags):
|
|
614
|
+
"""Test that timeout errors are captured specifically."""
|
|
615
|
+
from posthoganalytics.request import RequestsTimeout
|
|
616
|
+
|
|
617
|
+
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
|
618
|
+
|
|
619
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
620
|
+
|
|
621
|
+
self.assertIsNone(flag_result)
|
|
622
|
+
patch_capture.assert_called_with(
|
|
623
|
+
"$feature_flag_called",
|
|
624
|
+
distinct_id="some-distinct-id",
|
|
625
|
+
properties={
|
|
626
|
+
"$feature_flag": "my-flag",
|
|
627
|
+
"$feature_flag_response": None,
|
|
628
|
+
"locally_evaluated": False,
|
|
629
|
+
"$feature/my-flag": None,
|
|
630
|
+
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
|
631
|
+
},
|
|
632
|
+
groups={},
|
|
633
|
+
disable_geoip=None,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
@mock.patch("posthog.client.flags")
|
|
637
|
+
@mock.patch.object(Client, "capture")
|
|
638
|
+
def test_get_feature_flag_result_connection_error(self, patch_capture, patch_flags):
|
|
639
|
+
"""Test that connection errors are captured specifically."""
|
|
640
|
+
from posthoganalytics.request import RequestsConnectionError
|
|
641
|
+
|
|
642
|
+
patch_flags.side_effect = RequestsConnectionError("Connection refused")
|
|
643
|
+
|
|
644
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
645
|
+
|
|
646
|
+
self.assertIsNone(flag_result)
|
|
647
|
+
patch_capture.assert_called_with(
|
|
648
|
+
"$feature_flag_called",
|
|
649
|
+
distinct_id="some-distinct-id",
|
|
650
|
+
properties={
|
|
651
|
+
"$feature_flag": "my-flag",
|
|
652
|
+
"$feature_flag_response": None,
|
|
653
|
+
"locally_evaluated": False,
|
|
654
|
+
"$feature/my-flag": None,
|
|
655
|
+
"$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
|
|
656
|
+
},
|
|
657
|
+
groups={},
|
|
658
|
+
disable_geoip=None,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
@mock.patch("posthog.client.flags")
|
|
662
|
+
@mock.patch.object(Client, "capture")
|
|
663
|
+
def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags):
|
|
664
|
+
"""Test that API errors include the status code."""
|
|
665
|
+
from posthoganalytics.request import APIError
|
|
666
|
+
|
|
667
|
+
patch_flags.side_effect = APIError(500, "Internal server error")
|
|
668
|
+
|
|
669
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
670
|
+
|
|
671
|
+
self.assertIsNone(flag_result)
|
|
672
|
+
patch_capture.assert_called_with(
|
|
673
|
+
"$feature_flag_called",
|
|
674
|
+
distinct_id="some-distinct-id",
|
|
675
|
+
properties={
|
|
676
|
+
"$feature_flag": "my-flag",
|
|
677
|
+
"$feature_flag_response": None,
|
|
678
|
+
"locally_evaluated": False,
|
|
679
|
+
"$feature/my-flag": None,
|
|
680
|
+
"$feature_flag_error": FeatureFlagError.api_error(500),
|
|
681
|
+
},
|
|
682
|
+
groups={},
|
|
683
|
+
disable_geoip=None,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
@mock.patch("posthog.client.flags")
|
|
687
|
+
@mock.patch.object(Client, "capture")
|
|
688
|
+
def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags):
|
|
689
|
+
"""Test that quota limit errors are captured specifically."""
|
|
690
|
+
from posthoganalytics.request import QuotaLimitError
|
|
691
|
+
|
|
692
|
+
patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
|
|
693
|
+
|
|
694
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
695
|
+
|
|
696
|
+
self.assertIsNone(flag_result)
|
|
697
|
+
patch_capture.assert_called_with(
|
|
698
|
+
"$feature_flag_called",
|
|
699
|
+
distinct_id="some-distinct-id",
|
|
700
|
+
properties={
|
|
701
|
+
"$feature_flag": "my-flag",
|
|
702
|
+
"$feature_flag_response": None,
|
|
703
|
+
"locally_evaluated": False,
|
|
704
|
+
"$feature/my-flag": None,
|
|
705
|
+
"$feature_flag_error": FeatureFlagError.QUOTA_LIMITED,
|
|
706
|
+
},
|
|
707
|
+
groups={},
|
|
708
|
+
disable_geoip=None,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
|
|
713
|
+
"""Tests for stale cache fallback behavior when flag evaluation fails.
|
|
714
|
+
|
|
715
|
+
When the PostHog API is unavailable (timeout, connection error, etc.), the SDK
|
|
716
|
+
falls back to stale cached flag values if available. These tests verify that:
|
|
717
|
+
1. The stale cached value is returned when an error occurs
|
|
718
|
+
2. The $feature_flag_error property is still set (for debugging)
|
|
719
|
+
3. The response reflects the cached value, not None
|
|
720
|
+
"""
|
|
721
|
+
|
|
722
|
+
def set_fail(self, e, batch):
|
|
723
|
+
"""Mark the failure handler"""
|
|
724
|
+
self.failed = True
|
|
725
|
+
|
|
726
|
+
def setUp(self):
|
|
727
|
+
self.failed = False
|
|
728
|
+
# Create client with memory-based flag cache enabled
|
|
729
|
+
self.client = Client(
|
|
730
|
+
FAKE_TEST_API_KEY,
|
|
731
|
+
on_error=self.set_fail,
|
|
732
|
+
flag_fallback_cache_url="memory://local/?ttl=300&size=10000",
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def _populate_stale_cache(self, distinct_id, flag_key, flag_result):
|
|
736
|
+
"""Pre-populate the flag cache with a value that will be used for stale fallback."""
|
|
737
|
+
self.client.flag_cache.set_cached_flag(
|
|
738
|
+
distinct_id,
|
|
739
|
+
flag_key,
|
|
740
|
+
flag_result,
|
|
741
|
+
flag_definition_version=self.client.flag_definition_version,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
@mock.patch("posthog.client.flags")
|
|
745
|
+
@mock.patch.object(Client, "capture")
|
|
746
|
+
def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_flags):
|
|
747
|
+
"""Test that timeout errors return stale cached value when available."""
|
|
748
|
+
from posthoganalytics.request import RequestsTimeout
|
|
749
|
+
|
|
750
|
+
# Pre-populate cache with a flag result
|
|
751
|
+
cached_result = FeatureFlagResult.from_value_and_payload(
|
|
752
|
+
"my-flag", "cached-variant", '{"from": "cache"}'
|
|
753
|
+
)
|
|
754
|
+
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
|
755
|
+
|
|
756
|
+
# Simulate timeout error
|
|
757
|
+
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
|
758
|
+
|
|
759
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
760
|
+
|
|
761
|
+
# Should return the stale cached value
|
|
762
|
+
self.assertIsNotNone(flag_result)
|
|
763
|
+
self.assertEqual(flag_result.variant, "cached-variant")
|
|
764
|
+
self.assertEqual(flag_result.payload, {"from": "cache"})
|
|
765
|
+
|
|
766
|
+
# Error should still be tracked for debugging
|
|
767
|
+
patch_capture.assert_called_with(
|
|
768
|
+
"$feature_flag_called",
|
|
769
|
+
distinct_id="some-distinct-id",
|
|
770
|
+
properties={
|
|
771
|
+
"$feature_flag": "my-flag",
|
|
772
|
+
"$feature_flag_response": "cached-variant",
|
|
773
|
+
"locally_evaluated": False,
|
|
774
|
+
"$feature/my-flag": "cached-variant",
|
|
775
|
+
"$feature_flag_payload": {"from": "cache"},
|
|
776
|
+
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
|
777
|
+
},
|
|
778
|
+
groups={},
|
|
779
|
+
disable_geoip=None,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
@mock.patch("posthog.client.flags")
|
|
783
|
+
@mock.patch.object(Client, "capture")
|
|
784
|
+
def test_connection_error_returns_stale_cached_value(
|
|
785
|
+
self, patch_capture, patch_flags
|
|
786
|
+
):
|
|
787
|
+
"""Test that connection errors return stale cached value when available."""
|
|
788
|
+
from posthoganalytics.request import RequestsConnectionError
|
|
789
|
+
|
|
790
|
+
# Pre-populate cache with a boolean flag result
|
|
791
|
+
cached_result = FeatureFlagResult.from_value_and_payload("my-flag", True, None)
|
|
792
|
+
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
|
793
|
+
|
|
794
|
+
# Simulate connection error
|
|
795
|
+
patch_flags.side_effect = RequestsConnectionError("Connection refused")
|
|
796
|
+
|
|
797
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
798
|
+
|
|
799
|
+
# Should return the stale cached value
|
|
800
|
+
self.assertIsNotNone(flag_result)
|
|
801
|
+
self.assertEqual(flag_result.enabled, True)
|
|
802
|
+
self.assertIsNone(flag_result.variant)
|
|
803
|
+
|
|
804
|
+
# Error should still be tracked
|
|
805
|
+
patch_capture.assert_called_with(
|
|
806
|
+
"$feature_flag_called",
|
|
807
|
+
distinct_id="some-distinct-id",
|
|
808
|
+
properties={
|
|
809
|
+
"$feature_flag": "my-flag",
|
|
810
|
+
"$feature_flag_response": True,
|
|
811
|
+
"locally_evaluated": False,
|
|
812
|
+
"$feature/my-flag": True,
|
|
813
|
+
"$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
|
|
814
|
+
},
|
|
815
|
+
groups={},
|
|
816
|
+
disable_geoip=None,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
@mock.patch("posthog.client.flags")
|
|
820
|
+
@mock.patch.object(Client, "capture")
|
|
821
|
+
def test_api_error_returns_stale_cached_value(self, patch_capture, patch_flags):
|
|
822
|
+
"""Test that API errors return stale cached value when available."""
|
|
823
|
+
from posthoganalytics.request import APIError
|
|
824
|
+
|
|
825
|
+
# Pre-populate cache
|
|
826
|
+
cached_result = FeatureFlagResult.from_value_and_payload(
|
|
827
|
+
"my-flag", "control", None
|
|
828
|
+
)
|
|
829
|
+
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
|
830
|
+
|
|
831
|
+
# Simulate API error
|
|
832
|
+
patch_flags.side_effect = APIError(503, "Service unavailable")
|
|
833
|
+
|
|
834
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
835
|
+
|
|
836
|
+
# Should return the stale cached value
|
|
837
|
+
self.assertIsNotNone(flag_result)
|
|
838
|
+
self.assertEqual(flag_result.variant, "control")
|
|
839
|
+
|
|
840
|
+
# Error should still be tracked with status code
|
|
841
|
+
patch_capture.assert_called_with(
|
|
842
|
+
"$feature_flag_called",
|
|
843
|
+
distinct_id="some-distinct-id",
|
|
844
|
+
properties={
|
|
845
|
+
"$feature_flag": "my-flag",
|
|
846
|
+
"$feature_flag_response": "control",
|
|
847
|
+
"locally_evaluated": False,
|
|
848
|
+
"$feature/my-flag": "control",
|
|
849
|
+
"$feature_flag_error": FeatureFlagError.api_error(503),
|
|
850
|
+
},
|
|
851
|
+
groups={},
|
|
852
|
+
disable_geoip=None,
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
@mock.patch("posthog.client.flags")
|
|
856
|
+
@mock.patch.object(Client, "capture")
|
|
857
|
+
def test_error_without_cache_returns_none(self, patch_capture, patch_flags):
|
|
858
|
+
"""Test that errors return None when no stale cache is available."""
|
|
859
|
+
from posthoganalytics.request import RequestsTimeout
|
|
860
|
+
|
|
861
|
+
# Do NOT populate cache - no fallback available
|
|
862
|
+
|
|
863
|
+
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
|
864
|
+
|
|
865
|
+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
|
866
|
+
|
|
867
|
+
# Should return None since no cache available
|
|
868
|
+
self.assertIsNone(flag_result)
|
|
869
|
+
|
|
870
|
+
# Error should still be tracked
|
|
871
|
+
patch_capture.assert_called_with(
|
|
872
|
+
"$feature_flag_called",
|
|
873
|
+
distinct_id="some-distinct-id",
|
|
874
|
+
properties={
|
|
875
|
+
"$feature_flag": "my-flag",
|
|
876
|
+
"$feature_flag_response": None,
|
|
877
|
+
"locally_evaluated": False,
|
|
878
|
+
"$feature/my-flag": None,
|
|
879
|
+
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
|
441
880
|
},
|
|
442
881
|
groups={},
|
|
443
882
|
disable_geoip=None,
|