diff --git a/posthog/__init__.py b/posthog/__init__.py index 871b3ceb..2184d372 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -322,6 +322,9 @@ def get_tags() -> Dict[str, Any]: attributed to the person normally. feature_flags_request_timeout_seconds: Timeout in seconds for feature flag and remote config requests. + feature_flags_request_max_retries: Number of retries for feature flag + requests after network, transport, or timeout failures. Defaults to 1. + Set to 0 to disable retries. super_properties: Properties merged into every captured event. enable_exception_autocapture: Automatically capture uncaught exceptions. log_captured_exceptions: Also log exceptions captured by error tracking. @@ -365,6 +368,7 @@ def get_tags() -> Dict[str, Any]: disable_geoip = True # type: bool is_server = True # type: bool feature_flags_request_timeout_seconds = 3 # type: int +feature_flags_request_max_retries = 1 # type: int super_properties = None # type: Optional[Dict] enable_exception_autocapture = False # type: bool log_captured_exceptions = False # type: bool @@ -1156,6 +1160,7 @@ def setup() -> Client: disable_geoip=disable_geoip, is_server=is_server, feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds, + feature_flags_request_max_retries=feature_flags_request_max_retries, super_properties=super_properties, # TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK) # This kind of initialisation is very annoying for exception capture. We need to figure out a way around this, diff --git a/posthog/client.py b/posthog/client.py index 1fbff223..722c453b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -240,6 +240,7 @@ def __init__( is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, + feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, @@ -296,6 +297,9 @@ def __init__( historical_migration: Mark events as historical migration imports. feature_flags_request_timeout_seconds: Timeout in seconds for feature flag and remote config requests. + feature_flags_request_max_retries: Number of retries for feature flag + requests after network, transport, or timeout failures. Defaults + to 1. Set to 0 to disable retries. super_properties: Properties merged into every captured event. enable_exception_autocapture: Automatically capture uncaught exceptions. @@ -376,6 +380,9 @@ def __init__( self.feature_flags_request_timeout_seconds = ( feature_flags_request_timeout_seconds ) + self.feature_flags_request_max_retries = max( + 0, feature_flags_request_max_retries + ) self.poller: Optional[Poller] = None self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set) self.flag_fallback_cache_url = flag_fallback_cache_url @@ -899,6 +906,7 @@ def _get_flags_decision( self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, + max_retries=self.feature_flags_request_max_retries, **request_data, ) diff --git a/posthog/request.py b/posthog/request.py index de16f33a..c69ea465 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -2,6 +2,7 @@ import logging import re import socket +import time from dataclasses import dataclass from datetime import date, datetime, timezone from gzip import GzipFile @@ -41,8 +42,7 @@ if hasattr(socket, attr): KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value)) -# Status codes that indicate transient server errors worth retrying -RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504] +_FEATURE_FLAGS_RETRY_BACKOFF_SECONDS = 0.3 def _mask_tokens_in_url(url: str) -> str: @@ -90,23 +90,14 @@ def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.S def _build_flags_session( socket_options: Optional[SocketOptions] = None, ) -> requests.Session: - """ - Build a session for feature flag requests with POST retries. + """Build a session for feature flag requests. - Feature flag requests are idempotent (read-only), so retrying POST - requests is safe. This session retries on transient server errors - (408, 5xx) and network failures with exponential backoff - (0.5s, 1s delays between retries). + /flags retries are handled explicitly in ``flags()`` so that only + transport failures are retried. HTTP status responses must surface as API + errors without retrying. """ adapter = HTTPAdapterWithSocketOptions( - max_retries=Retry( - total=2, - connect=2, - read=2, - backoff_factor=0.5, - status_forcelist=RETRY_STATUS_FORCELIST, - allowed_methods=["POST"], - ), + max_retries=Retry(total=0, connect=0, read=0, status=0), socket_options=socket_options, ) session = requests.Session() @@ -306,26 +297,41 @@ def _process_response( raise APIError(res.status_code, res.text, retry_after=retry_after) +def _feature_flags_retry_delay(failed_attempt: int) -> float: + return _FEATURE_FLAGS_RETRY_BACKOFF_SECONDS * (2**failed_attempt) + + def flags( api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, + max_retries: int = 1, **kwargs, ) -> Any: - """Post the kwargs to the flags API endpoint with automatic retries.""" - res = post( - api_key, - host, - "/flags/?v=2", - gzip, - timeout, - session=_get_flags_session(), - **kwargs, - ) - return _process_response( - res, success_message="Feature flags evaluated successfully" - ) + """Post the kwargs to the flags API endpoint with transport retries.""" + retries = max(0, max_retries) + failed_attempt = 0 + + while True: + try: + res = post( + api_key, + host, + "/flags/?v=2", + gzip, + timeout, + session=_get_flags_session(), + **kwargs, + ) + return _process_response( + res, success_message="Feature flags evaluated successfully" + ) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + if failed_attempt >= retries: + raise + time.sleep(_feature_flags_retry_delay(failed_attempt)) + failed_attempt += 1 def remote_config( diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 10a173fa..b6030522 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -976,6 +976,7 @@ def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags) "random_key", "https://us.i.posthog.com", timeout=3, + max_retries=1, distinct_id="distinct_id", groups={}, person_properties={}, @@ -984,6 +985,30 @@ def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags) device_id=None, ) + @parameterized.expand( + [ + ("default", 1), + ("disabled", 0), + ("two_retries", 2), + ] + ) + def test_feature_flags_request_max_retries_is_forwarded( + self, _name, expected_max_retries + ): + with mock.patch("posthog.client.flags") as patch_flags: + patch_flags.return_value = {"featureFlags": {}, "featureFlagPayloads": {}} + client = Client( + FAKE_TEST_API_KEY, + feature_flags_request_max_retries=expected_max_retries, + personal_api_key=FAKE_TEST_API_KEY, + ) + + client.get_all_flags("distinct_id") + + self.assertEqual( + patch_flags.call_args.kwargs["max_retries"], expected_max_retries + ) + @mock.patch("posthog.client.flags") def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly( self, patch_flags @@ -1041,6 +1066,7 @@ def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly( "random_key", "https://us.i.posthog.com", timeout=12, + max_retries=1, distinct_id="distinct_id", groups={}, person_properties={}, @@ -2261,6 +2287,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): "random_key", "https://us.i.posthog.com", timeout=3, + max_retries=1, distinct_id="some_id", groups={}, person_properties={"distinct_id": "some_id"}, @@ -2277,6 +2304,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): "random_key", "https://us.i.posthog.com", timeout=3, + max_retries=1, distinct_id="feature_enabled_distinct_id", groups={}, person_properties={"distinct_id": "feature_enabled_distinct_id"}, @@ -2291,6 +2319,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): "random_key", "https://us.i.posthog.com", timeout=3, + max_retries=1, distinct_id="all_flags_payloads_id", groups={}, person_properties={"distinct_id": "all_flags_payloads_id"}, @@ -2337,6 +2366,7 @@ def test_default_properties_get_added_properly(self, patch_flags): "random_key", "http://app2.posthog.com", timeout=3, + max_retries=1, distinct_id="some_id", groups={"company": "id:5", "instance": "app.posthog.com"}, person_properties={"distinct_id": "some_id", "x1": "y1"}, @@ -2365,6 +2395,7 @@ def test_default_properties_get_added_properly(self, patch_flags): "random_key", "http://app2.posthog.com", timeout=3, + max_retries=1, distinct_id="some_id", groups={"company": "id:5", "instance": "app.posthog.com"}, person_properties={"distinct_id": "override"}, @@ -2386,6 +2417,7 @@ def test_default_properties_get_added_properly(self, patch_flags): "random_key", "http://app2.posthog.com", timeout=3, + max_retries=1, distinct_id="some_id", groups={}, person_properties={"distinct_id": "some_id"}, @@ -2446,7 +2478,11 @@ def test_device_id_is_passed_to_flags_request( expected_call["flag_keys_to_evaluate"] = expected_flag_keys patch_flags.assert_called_with( - "random_key", "https://us.i.posthog.com", timeout=3, **expected_call + "random_key", + "https://us.i.posthog.com", + timeout=3, + max_retries=1, + **expected_call, ) @mock.patch("posthog.client.flags") @@ -2472,6 +2508,7 @@ def test_device_id_from_context_is_used_in_flags_request(self, patch_flags): "random_key", "https://us.i.posthog.com", timeout=3, + max_retries=1, distinct_id="some_id", groups={}, person_properties={"distinct_id": "some_id"}, @@ -2492,6 +2529,7 @@ def test_device_id_from_context_is_used_in_flags_request(self, patch_flags): "random_key", "https://us.i.posthog.com", timeout=3, + max_retries=1, distinct_id="some_id", groups={}, person_properties={"distinct_id": "some_id"}, @@ -2521,6 +2559,7 @@ def test_client_set_context_device_id_is_used_in_flags_request(self, patch_flags "random_key", "https://us.i.posthog.com", timeout=3, + max_retries=1, distinct_id="some_id", groups={}, person_properties={"distinct_id": "some_id"}, diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index 4cae68f9..cd20485c 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -504,17 +504,18 @@ def test_set_socket_options_is_idempotent(): class TestFlagsSession(unittest.TestCase): """Tests for flags session configuration.""" - def test_retry_status_forcelist_excludes_rate_limits(self): - """Verify 429 (rate limit) is NOT retried - need to wait, not hammer.""" - from posthog.request import RETRY_STATUS_FORCELIST - - self.assertNotIn(429, RETRY_STATUS_FORCELIST) + def test_flags_session_disables_adapter_retries(self): + """HTTP adapter retries are disabled; flags() retries transport errors.""" + from posthog.request import _build_flags_session - def test_retry_status_forcelist_excludes_quota_errors(self): - """Verify 402 (payment required/quota) is NOT retried - won't resolve.""" - from posthog.request import RETRY_STATUS_FORCELIST + session = _build_flags_session() + adapter = session.get_adapter("https://test.posthog.com") + retry = adapter.max_retries - self.assertNotIn(402, RETRY_STATUS_FORCELIST) + self.assertEqual(retry.total, 0) + self.assertEqual(retry.connect, 0) + self.assertEqual(retry.read, 0) + self.assertEqual(retry.status, 0) @mock.patch("posthog.request._get_flags_session") def test_flags_uses_flags_session(self, mock_get_flags_session): @@ -564,208 +565,105 @@ def test_flags_no_retry_on_quota_limit(self, mock_get_flags_session): self.assertEqual(mock_session.post.call_count, 1) -class TestFlagsSessionNetworkRetries(unittest.TestCase): - """Tests for network failure retries in the flags session.""" - - def test_flags_session_retry_config_includes_connection_errors(self): - """ - Verify that the flags session is configured to retry on connection errors. - - The urllib3 Retry adapter with connect=2 and read=2 automatically - retries on network-level failures (DNS failures, connection refused, - connection reset, etc.) up to 2 times each. - """ - from posthog.request import _build_flags_session - - session = _build_flags_session() +class TestFlagsRetries(unittest.TestCase): + """Tests for /flags retry behavior.""" - # Get the adapter for https:// - adapter = session.get_adapter("https://test.posthog.com") + @mock.patch("posthog.request.time.sleep") + @mock.patch("posthog.request._get_flags_session") + def test_flags_retries_transport_errors_once_by_default( + self, mock_get_flags_session, mock_sleep + ): + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = json.dumps( + { + "featureFlags": {"test-flag": True}, + "featureFlagPayloads": {}, + "errorsWhileComputingFlags": False, + } + ).encode("utf-8") - # Verify retry configuration - retry = adapter.max_retries - self.assertEqual(retry.total, 2, "Should have 2 total retries") - self.assertEqual(retry.connect, 2, "Should retry connection errors twice") - self.assertEqual(retry.read, 2, "Should retry read errors twice") - self.assertIn("POST", retry.allowed_methods, "Should allow POST retries") + mock_session = mock.MagicMock() + mock_session.post.side_effect = [ + requests.exceptions.ConnectionError("connection failed"), + mock_response, + ] + mock_get_flags_session.return_value = mock_session - def test_flags_session_retries_on_server_errors(self): - """ - Verify that transient server errors (5xx) trigger retries. + response = flags("test-key", "https://test.posthog.com", distinct_id="user123") - This tests the status_forcelist configuration which specifies - which HTTP status codes should trigger a retry. - """ - from posthog.request import _build_flags_session, RETRY_STATUS_FORCELIST + self.assertEqual(response["featureFlags"], {"test-flag": True}) + self.assertEqual(mock_session.post.call_count, 2) + mock_sleep.assert_called_once_with(0.3) - session = _build_flags_session() - adapter = session.get_adapter("https://test.posthog.com") - retry = adapter.max_retries + @mock.patch("posthog.request.time.sleep") + @mock.patch("posthog.request._get_flags_session") + def test_flags_retry_count_zero_disables_retries( + self, mock_get_flags_session, mock_sleep + ): + mock_session = mock.MagicMock() + mock_session.post.side_effect = requests.exceptions.Timeout("timed out") + mock_get_flags_session.return_value = mock_session - # Verify the status codes that trigger retries - self.assertEqual( - set(retry.status_forcelist), - set(RETRY_STATUS_FORCELIST), - "Should retry on transient server errors", - ) + with self.assertRaises(requests.exceptions.Timeout): + flags( + "test-key", + "https://test.posthog.com", + max_retries=0, + distinct_id="user123", + ) - # Verify specific codes are included - self.assertIn(500, retry.status_forcelist) - self.assertIn(502, retry.status_forcelist) - self.assertIn(503, retry.status_forcelist) - self.assertIn(504, retry.status_forcelist) + self.assertEqual(mock_session.post.call_count, 1) + mock_sleep.assert_not_called() - # Verify rate limits and quota errors are NOT retried - self.assertNotIn(429, retry.status_forcelist) - self.assertNotIn(402, retry.status_forcelist) + @mock.patch("posthog.request.time.sleep") + @mock.patch("posthog.request._get_flags_session") + def test_flags_retry_delay_starts_at_300ms_and_doubles( + self, mock_get_flags_session, mock_sleep + ): + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = json.dumps( + { + "featureFlags": {"test-flag": True}, + "featureFlagPayloads": {}, + "errorsWhileComputingFlags": False, + } + ).encode("utf-8") - def test_flags_session_has_backoff(self): - """ - Verify that retries use exponential backoff to avoid thundering herd. - """ - from posthog.request import _build_flags_session + mock_session = mock.MagicMock() + mock_session.post.side_effect = [ + requests.exceptions.ConnectionError("connection failed"), + requests.exceptions.Timeout("timed out"), + mock_response, + ] + mock_get_flags_session.return_value = mock_session - session = _build_flags_session() - adapter = session.get_adapter("https://test.posthog.com") - retry = adapter.max_retries + response = flags( + "test-key", + "https://test.posthog.com", + max_retries=2, + distinct_id="user123", + ) + self.assertEqual(response["featureFlags"], {"test-flag": True}) + self.assertEqual(mock_session.post.call_count, 3) self.assertEqual( - retry.backoff_factor, - 0.5, - "Should use 0.5s backoff factor (0.5s, 1s delays)", + [call.args[0] for call in mock_sleep.call_args_list], [0.3, 0.6] ) + @mock.patch("posthog.request._get_flags_session") + def test_flags_does_not_retry_http_status_errors(self, mock_get_flags_session): + mock_response = requests.Response() + mock_response.status_code = 503 + mock_response._content = b'{"detail": "Service unavailable"}' -class TestFlagsSessionRetryIntegration(unittest.TestCase): - """Integration tests that verify actual retry behavior with a local server.""" - - def test_retries_on_503_then_succeeds(self): - """ - Verify that 503 errors trigger retries and eventually succeed. - - Uses a local HTTP server that fails twice with 503, then succeeds. - This tests the full retry flow including backoff timing. - """ - import threading - from http.server import HTTPServer, BaseHTTPRequestHandler - from socketserver import ThreadingMixIn - from urllib3.util.retry import Retry - from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST - - request_count = 0 - - class RetryTestHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def do_POST(self): - nonlocal request_count - request_count += 1 - - # Read and discard request body to prevent connection issues - content_length = int(self.headers.get("Content-Length", 0)) - if content_length > 0: - self.rfile.read(content_length) - - if request_count <= 2: - self.send_response(503) - self.send_header("Content-Type", "application/json") - body = b'{"error": "Service unavailable"}' - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(200) - self.send_header("Content-Type", "application/json") - body = ( - b'{"featureFlags": {"test": true}, "featureFlagPayloads": {}}' - ) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, format, *args): - pass # Suppress logging - - # Use ThreadingMixIn for cleaner shutdown - class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): - daemon_threads = True - - # Start server on a random available port - server = ThreadedHTTPServer(("127.0.0.1", 0), RetryTestHandler) - port = server.server_address[1] - server_thread = threading.Thread(target=server.serve_forever) - server_thread.daemon = True - server_thread.start() - - try: - # Build session with same retry config as _build_flags_session - # but mounted on http:// for local testing - adapter = HTTPAdapterWithSocketOptions( - max_retries=Retry( - total=2, - connect=2, - read=2, - backoff_factor=0.01, # Fast backoff for testing - status_forcelist=RETRY_STATUS_FORCELIST, - allowed_methods=["POST"], - ), - ) - session = requests.Session() - session.mount("http://", adapter) - - response = session.post( - f"http://127.0.0.1:{port}/flags/?v=2", - json={"distinct_id": "user123"}, - timeout=5, - ) + mock_session = mock.MagicMock() + mock_session.post.return_value = mock_response + mock_get_flags_session.return_value = mock_session - # Should succeed on 3rd attempt - self.assertEqual(response.status_code, 200) - self.assertEqual(request_count, 3) # 1 initial + 2 retries - finally: - server.shutdown() - server.server_close() - - def test_connection_errors_are_retried(self): - """ - Verify that connection errors (no server) trigger retries. - - Binds a socket to get a guaranteed available port, then closes it - so connection attempts fail with ConnectionError. - """ - import socket - import time - from urllib3.util.retry import Retry - from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST - - # Get an available port by binding then closing a socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - sock.close() # Port is now available but nothing is listening - - adapter = HTTPAdapterWithSocketOptions( - max_retries=Retry( - total=2, - connect=2, - read=2, - backoff_factor=0.05, # Very fast for testing - status_forcelist=RETRY_STATUS_FORCELIST, - allowed_methods=["POST"], - ), - ) - session = requests.Session() - session.mount("http://", adapter) - - start = time.time() - with self.assertRaises(requests.exceptions.ConnectionError): - session.post( - f"http://127.0.0.1:{port}/flags/?v=2", - json={"distinct_id": "user123"}, - timeout=1, - ) - elapsed = time.time() - start + with self.assertRaises(APIError) as cm: + flags("test-key", "https://test.posthog.com", distinct_id="user123") - # With 3 attempts and backoff, should take more than instant - # but less than timeout (confirms retries happened) - self.assertGreater(elapsed, 0.05, "Should have some delay from retries") + self.assertEqual(cm.exception.status, 503) + self.assertEqual(mock_session.post.call_count, 1) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 772cf18d..ef176a91 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -492,6 +492,7 @@ attribute posthog.client.Client.exception_autocapture_refill_rate = exception_au attribute posthog.client.Client.exception_capture = None attribute posthog.client.Client.feature_flags attribute posthog.client.Client.feature_flags_by_key: Optional[dict[str, Any]] = None +attribute posthog.client.Client.feature_flags_request_max_retries = max(0, feature_flags_request_max_retries) attribute posthog.client.Client.feature_flags_request_timeout_seconds = feature_flags_request_timeout_seconds attribute posthog.client.Client.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url) attribute posthog.client.Client.flag_definition_version = 0 @@ -602,6 +603,7 @@ attribute posthog.feature_flags.SEMVER_OPERATORS = SEMVER_COMPARISON_OPERATORS + attribute posthog.feature_flags.SEMVER_RANGE_OPERATORS = ('semver_tilde', 'semver_caret', 'semver_wildcard') attribute posthog.feature_flags.STRING_OPERATORS = ('icontains', 'not_icontains', 'regex', 'not_regex') attribute posthog.feature_flags.log = logging.getLogger('posthog') +attribute posthog.feature_flags_request_max_retries = 1 attribute posthog.feature_flags_request_timeout_seconds = 3 attribute posthog.flag_definition_cache.FlagDefinitionCacheData.cohorts: Required[Dict[str, Any]] attribute posthog.flag_definition_cache.FlagDefinitionCacheData.flags: Required[List[Dict[str, Any]]] @@ -702,7 +704,6 @@ attribute posthog.request.KEEPALIVE_IDLE_SECONDS = 60 attribute posthog.request.KEEPALIVE_INTERVAL_SECONDS = 60 attribute posthog.request.KEEPALIVE_PROBE_COUNT = 3 attribute posthog.request.KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list(HTTPConnection.default_socket_options) + [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] -attribute posthog.request.RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504] attribute posthog.request.RequestsConnectionError = requests.exceptions.ConnectionError attribute posthog.request.RequestsTimeout = requests.exceptions.Timeout attribute posthog.request.SocketOptions = List[Tuple[int, int, Union[int, bytes]]] @@ -826,7 +827,7 @@ class posthog.ai.types.ToolInProgress class posthog.args.OptionalCaptureArgs class posthog.args.OptionalSetArgs class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic) -class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False) +class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, dedicated_ai_endpoint=False) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) @@ -1043,7 +1044,7 @@ function posthog.request.batch_post(api_key: str, host: Optional[str] = None, gz function posthog.request.determine_server_host(host: Optional[str]) -> str function posthog.request.disable_connection_reuse() -> None function posthog.request.enable_keep_alive() -> None -function posthog.request.flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any +function posthog.request.flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, max_retries: int = 1, **kwargs) -> Any function posthog.request.get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None, etag: Optional[str] = None) -> GetResponse function posthog.request.is_ai_event(event_name) -> bool function posthog.request.normalize_host(host: Optional[str]) -> str