From 0cb25c2d729abc55100b12498dc4f2bf7cbb4f5b Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:37:21 -0300 Subject: [PATCH 1/3] fix(flags): do not retry a refused flags connection A refused connection is not transient, so flags() now raises it on the first attempt instead of retrying it on the backoff schedule. Timeouts, resets, DNS and TLS failures are still retried. --- .../flags-no-retry-connection-refused.md | 5 ++++ posthog/request.py | 27 +++++++++++++++++-- posthog/test/test_request.py | 20 ++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 .sampo/changesets/flags-no-retry-connection-refused.md diff --git a/.sampo/changesets/flags-no-retry-connection-refused.md b/.sampo/changesets/flags-no-retry-connection-refused.md new file mode 100644 index 000000000..ae47512db --- /dev/null +++ b/.sampo/changesets/flags-no-retry-connection-refused.md @@ -0,0 +1,5 @@ +--- +posthog: patch +--- + +Stop retrying `/flags` requests when the connection is refused, so the flag error surfaces right away instead of after the retry backoff. diff --git a/posthog/request.py b/posthog/request.py index 03316fc1f..231ac42cf 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -322,6 +322,25 @@ def _process_response( raise APIError(res.status_code, res.text, retry_after=retry_after) +def _is_connection_refused(exc: BaseException) -> bool: + """Return True when a requests transport error wraps a refused connection.""" + pending: List[Any] = [exc] + seen = set() + while pending: + current = pending.pop() + if current is None or id(current) in seen: + continue + seen.add(id(current)) + if isinstance(current, ConnectionRefusedError): + return True + if isinstance(current, BaseException): + pending.extend(current.args) + pending.append(current.__cause__) + pending.append(current.__context__) + pending.append(getattr(current, "reason", None)) + return False + + def _feature_flags_retry_delay(failed_attempt: int) -> float: return _FEATURE_FLAGS_RETRY_BACKOFF_SECONDS * (2**failed_attempt) @@ -354,8 +373,12 @@ def flags( return _process_response( res, success_message="Feature flags evaluated successfully" ) - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): - if failed_attempt >= retries: + except ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + ) as exc: + # A refused connection is not transient, so it is not retried. + if failed_attempt >= retries or _is_connection_refused(exc): raise except APIError as exc: if ( diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index e23b0a446..015f30525 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -1,5 +1,6 @@ import gzip import json +import socket import unittest import zlib from datetime import date, datetime, timedelta @@ -702,6 +703,25 @@ def test_flags_retries_transport_errors_once_by_default( self.assertEqual(mock_session.post.call_count, 2) mock_sleep.assert_called_once_with(0.3) + @mock.patch("posthog.request.time.sleep") + def test_flags_does_not_retry_refused_connection(self, mock_sleep): + # Bind and close a socket so the port refuses connections. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + with mock.patch("posthog.request.post", wraps=request_module.post) as mock_post: + with self.assertRaises(requests.exceptions.ConnectionError): + flags( + "test-key", + f"http://127.0.0.1:{port}", + max_retries=2, + distinct_id="user123", + ) + + self.assertEqual(mock_post.call_count, 1) + mock_sleep.assert_not_called() + @mock.patch("posthog.request.time.sleep") @mock.patch("posthog.request._get_flags_session") def test_flags_retry_count_zero_disables_retries( From 2a1ffb0c07f7a6db07a5ef2f441df6e7cd0a5d09 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:10:19 -0300 Subject: [PATCH 2/3] fix(flags): ignore unrelated handled exceptions when detecting a refusal A timeout raised while the application handles its own refused connection was treated as a refusal and not retried. --- posthog/request.py | 7 ++++++- posthog/test/test_request.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/posthog/request.py b/posthog/request.py index 231ac42cf..52854f922 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -15,6 +15,7 @@ import requests from requests.adapters import HTTPAdapter from urllib3.connection import HTTPConnection +from urllib3.exceptions import NewConnectionError from urllib3.util.retry import Retry from posthog._logging import _configure_posthog_logging @@ -336,7 +337,11 @@ def _is_connection_refused(exc: BaseException) -> bool: if isinstance(current, BaseException): pending.extend(current.args) pending.append(current.__cause__) - pending.append(current.__context__) + # __context__ records whatever exception was being handled, which may + # have nothing to do with this request. urllib3 1.x only links the + # socket error to NewConnectionError that way, so follow it just there. + if isinstance(current, NewConnectionError): + pending.append(current.__context__) pending.append(getattr(current, "reason", None)) return False diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index 015f30525..d9d44df79 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -722,6 +722,41 @@ def test_flags_does_not_retry_refused_connection(self, mock_sleep): self.assertEqual(mock_post.call_count, 1) mock_sleep.assert_not_called() + @mock.patch("posthog.request.time.sleep") + @mock.patch("posthog.request._get_flags_session") + def test_flags_retries_when_handling_an_unrelated_refused_connection( + 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") + + mock_session = mock.MagicMock() + mock_session.post.side_effect = [ + requests.exceptions.Timeout("timed out"), + mock_response, + ] + mock_get_flags_session.return_value = mock_session + + # The application evaluates flags while handling its own refused + # connection, so the timeout inherits that error as its __context__. + try: + raise ConnectionRefusedError("database is down") + except ConnectionRefusedError: + response = flags( + "test-key", "https://test.posthog.com", distinct_id="user123" + ) + + self.assertEqual(response["featureFlags"], {"test-flag": True}) + self.assertEqual(mock_session.post.call_count, 2) + mock_sleep.assert_called_once_with(0.3) + @mock.patch("posthog.request.time.sleep") @mock.patch("posthog.request._get_flags_session") def test_flags_retry_count_zero_disables_retries( From d73e4fdd3ef16f019664dcdb13101f211efc343d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 6 Nov 2026 09:05:46 +0100 Subject: [PATCH 3/3] fix: use namespaced package key in flags changeset --- .sampo/changesets/flags-no-retry-connection-refused.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sampo/changesets/flags-no-retry-connection-refused.md b/.sampo/changesets/flags-no-retry-connection-refused.md index ae47512db..e5a465d32 100644 --- a/.sampo/changesets/flags-no-retry-connection-refused.md +++ b/.sampo/changesets/flags-no-retry-connection-refused.md @@ -1,5 +1,5 @@ --- -posthog: patch +pypi/posthog: patch --- Stop retrying `/flags` requests when the connection is refused, so the flag error surfaces right away instead of after the retry backoff.