Skip to content

Commit 28c7e09

Browse files
committed
Add urllib3-based retry for feature flag requests
Use urllib3's built-in Retry mechanism for feature flag POST requests instead of application-level retry logic. This is simpler and leverages well-tested library code. Key changes: - Add `RETRY_STATUS_FORCELIST` = [408, 500, 502, 503, 504] - Add `_build_flags_session()` with POST retries and `status_forcelist` - Update `flags()` to use dedicated flags session - Add tests for retry configuration and session usage The flags session retries on: - Network failures (connect/read errors) - Transient server errors (408, 500, 502, 503, 504) It does NOT retry on: - 429 (rate limit) - need to wait, not hammer - 402 (quota limit) - won't resolve with retries
1 parent b179280 commit 28c7e09

File tree

2 files changed

+122
-6
lines changed

2 files changed

+122
-6
lines changed

posthog/request.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from io import BytesIO
99
from typing import Any, List, Optional, Tuple, Union
1010

11-
1211
import requests
1312
from dateutil.tz import tzutc
1413
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
@@ -42,6 +41,9 @@
4241
if hasattr(socket, attr):
4342
KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
4443

44+
# Status codes that indicate transient server errors worth retrying
45+
RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504]
46+
4547

4648
def _mask_tokens_in_url(url: str) -> str:
4749
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
@@ -71,6 +73,7 @@ def init_poolmanager(self, *args, **kwargs):
7173

7274

7375
def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
76+
"""Build a session for general requests (batch, decide, etc.)."""
7477
adapter = HTTPAdapterWithSocketOptions(
7578
max_retries=Retry(
7679
total=2,
@@ -79,12 +82,38 @@ def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.S
7982
),
8083
socket_options=socket_options,
8184
)
82-
session = requests.sessions.Session()
85+
session = requests.Session()
86+
session.mount("https://", adapter)
87+
return session
88+
89+
90+
def _build_flags_session(
91+
socket_options: Optional[SocketOptions] = None,
92+
) -> requests.Session:
93+
"""
94+
Build a session for feature flag requests with POST retries.
95+
96+
Feature flag requests are idempotent (read-only), so retrying POST
97+
requests is safe. This session retries on transient server errors
98+
(408, 5xx) and network failures.
99+
"""
100+
adapter = HTTPAdapterWithSocketOptions(
101+
max_retries=Retry(
102+
total=2,
103+
connect=2,
104+
read=2,
105+
status_forcelist=RETRY_STATUS_FORCELIST,
106+
allowed_methods=["POST"],
107+
),
108+
socket_options=socket_options,
109+
)
110+
session = requests.Session()
83111
session.mount("https://", adapter)
84112
return session
85113

86114

87115
_session = _build_session()
116+
_flags_session = _build_flags_session()
88117
_socket_options: Optional[SocketOptions] = None
89118
_pooling_enabled = True
90119

@@ -95,6 +124,12 @@ def _get_session() -> requests.Session:
95124
return _build_session(_socket_options)
96125

97126

127+
def _get_flags_session() -> requests.Session:
128+
if _pooling_enabled:
129+
return _flags_session
130+
return _build_flags_session(_socket_options)
131+
132+
98133
def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
99134
"""
100135
Configure socket options for all HTTP connections.
@@ -103,11 +138,12 @@ def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
103138
from posthog import set_socket_options
104139
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
105140
"""
106-
global _session, _socket_options
141+
global _session, _flags_session, _socket_options
107142
if socket_options == _socket_options:
108143
return
109144
_socket_options = socket_options
110145
_session = _build_session(socket_options)
146+
_flags_session = _build_flags_session(socket_options)
111147

112148

113149
def enable_keep_alive() -> None:
@@ -145,6 +181,7 @@ def post(
145181
path=None,
146182
gzip: bool = False,
147183
timeout: int = 15,
184+
session: Optional[requests.Session] = None,
148185
**kwargs,
149186
) -> requests.Response:
150187
"""Post the `kwargs` to the API"""
@@ -165,7 +202,9 @@ def post(
165202
gz.write(data.encode("utf-8"))
166203
data = buf.getvalue()
167204

168-
res = _get_session().post(url, data=data, headers=headers, timeout=timeout)
205+
res = (session or _get_session()).post(
206+
url, data=data, headers=headers, timeout=timeout
207+
)
169208

170209
if res.status_code == 200:
171210
log.debug("data uploaded successfully")
@@ -221,8 +260,21 @@ def flags(
221260
timeout: int = 15,
222261
**kwargs,
223262
) -> Any:
224-
"""Post the `kwargs to the flags API endpoint"""
225-
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
263+
"""
264+
Post the kwargs to the flags API endpoint.
265+
266+
Uses a session configured to retry on transient server errors (408, 5xx)
267+
and network failures. Retries are handled by urllib3 with exponential backoff.
268+
"""
269+
res = post(
270+
api_key,
271+
host,
272+
"/flags/?v=2",
273+
gzip,
274+
timeout,
275+
session=_get_flags_session(),
276+
**kwargs,
277+
)
226278
return _process_response(
227279
res, success_message="Feature flags evaluated successfully"
228280
)

posthog/test/test_request.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
determine_server_host,
2020
disable_connection_reuse,
2121
enable_keep_alive,
22+
flags,
2223
get,
2324
set_socket_options,
2425
)
@@ -393,3 +394,66 @@ def test_set_socket_options_is_idempotent():
393394
assert session1 is session2
394395
finally:
395396
set_socket_options(None)
397+
398+
399+
class TestFlagsSession(unittest.TestCase):
400+
"""Tests for flags session configuration."""
401+
402+
def test_retry_status_forcelist_excludes_rate_limits(self):
403+
"""Verify 429 (rate limit) is NOT retried - need to wait, not hammer."""
404+
from posthog.request import RETRY_STATUS_FORCELIST
405+
406+
self.assertNotIn(429, RETRY_STATUS_FORCELIST)
407+
408+
def test_retry_status_forcelist_excludes_quota_errors(self):
409+
"""Verify 402 (payment required/quota) is NOT retried - won't resolve."""
410+
from posthog.request import RETRY_STATUS_FORCELIST
411+
412+
self.assertNotIn(402, RETRY_STATUS_FORCELIST)
413+
414+
@mock.patch("posthog.request._get_flags_session")
415+
def test_flags_uses_flags_session(self, mock_get_flags_session):
416+
"""flags() uses the dedicated flags session, not the general session."""
417+
mock_response = requests.Response()
418+
mock_response.status_code = 200
419+
mock_response._content = json.dumps(
420+
{
421+
"featureFlags": {"test-flag": True},
422+
"featureFlagPayloads": {},
423+
"errorsWhileComputingFlags": False,
424+
}
425+
).encode("utf-8")
426+
427+
mock_session = mock.MagicMock()
428+
mock_session.post.return_value = mock_response
429+
mock_get_flags_session.return_value = mock_session
430+
431+
result = flags("test-key", "https://test.posthog.com", distinct_id="user123")
432+
433+
self.assertEqual(result["featureFlags"]["test-flag"], True)
434+
mock_get_flags_session.assert_called_once()
435+
mock_session.post.assert_called_once()
436+
437+
@mock.patch("posthog.request._get_flags_session")
438+
def test_flags_no_retry_on_quota_limit(self, mock_get_flags_session):
439+
"""flags() raises QuotaLimitError without retrying (at application level)."""
440+
mock_response = requests.Response()
441+
mock_response.status_code = 200
442+
mock_response._content = json.dumps(
443+
{
444+
"quotaLimited": ["feature_flags"],
445+
"featureFlags": {},
446+
"featureFlagPayloads": {},
447+
"errorsWhileComputingFlags": False,
448+
}
449+
).encode("utf-8")
450+
451+
mock_session = mock.MagicMock()
452+
mock_session.post.return_value = mock_response
453+
mock_get_flags_session.return_value = mock_session
454+
455+
with self.assertRaises(QuotaLimitError):
456+
flags("test-key", "https://test.posthog.com", distinct_id="user123")
457+
458+
# QuotaLimitError is raised after response is received, not retried
459+
self.assertEqual(mock_session.post.call_count, 1)

0 commit comments

Comments
 (0)