Skip to content

Commit 5d0bae1

Browse files
authored
feat(flags): Add retry support for feature flag requests (#392)
* 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 * Make examples run without requiring personal api key * Add integration tests for network retry behavior Add tests that verify actual retry behavior, not just configuration: - test_retries_on_503_then_succeeds: Spins up a local HTTP server that returns 503 twice then 200, verifying 3 requests are made - test_connection_errors_are_retried: Verifies connection errors trigger retries by measuring elapsed time with backoff Both tests use dynamically allocated ports for CI safety. * Bump version to 7.4.0
1 parent b179280 commit 5d0bae1

File tree

5 files changed

+405
-68
lines changed

5 files changed

+405
-68
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
# 7.4.0 - 2025-12-16
2+
3+
feat: Add automatic retries for feature flag requests
4+
5+
Feature flag API requests now automatically retry on transient failures:
6+
- Network errors (connection refused, DNS failures, timeouts)
7+
- Server errors (500, 502, 503, 504)
8+
- Up to 2 retries with exponential backoff (0.5s, 1s delays)
9+
10+
Rate limit (429) and quota (402) errors are not retried.
11+
112
# 7.3.1 - 2025-12-06
213

314
fix: remove unused $exception_message and $exception_type

example.py

Lines changed: 67 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -35,54 +35,40 @@ def load_env_file():
3535
personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "")
3636
host = os.getenv("POSTHOG_HOST", "http://localhost:8000")
3737

38-
# Check if credentials are provided
39-
if not project_key or not personal_api_key:
40-
print("❌ Missing PostHog credentials!")
41-
print(
42-
" Please set POSTHOG_PROJECT_API_KEY and POSTHOG_PERSONAL_API_KEY environment variables"
43-
)
38+
# Check if project key is provided (required)
39+
if not project_key:
40+
print("❌ Missing PostHog project API key!")
41+
print(" Please set POSTHOG_PROJECT_API_KEY environment variable")
4442
print(" or copy .env.example to .env and fill in your values")
4543
exit(1)
4644

47-
# Test authentication before proceeding
48-
print("🔑 Testing PostHog authentication...")
45+
# Configure PostHog with credentials
46+
posthog.debug = False
47+
posthog.api_key = project_key
48+
posthog.project_api_key = project_key
49+
posthog.host = host
50+
posthog.poll_interval = 10
4951

50-
try:
51-
# Configure PostHog with credentials
52-
posthog.debug = False # Keep quiet during auth test
53-
posthog.api_key = project_key
54-
posthog.project_api_key = project_key
52+
# Check if personal API key is available for local evaluation
53+
local_eval_available = bool(personal_api_key)
54+
if personal_api_key:
5555
posthog.personal_api_key = personal_api_key
56-
posthog.host = host
57-
posthog.poll_interval = 10
58-
59-
# Test by attempting to get feature flags (this validates both keys)
60-
# This will fail if credentials are invalid
61-
test_flags = posthog.get_all_flags("test_user", only_evaluate_locally=True)
62-
63-
# If we get here without exception, credentials work
64-
print("✅ Authentication successful!")
65-
print(f" Project API Key: {project_key[:9]}...")
66-
print(" Personal API Key: [REDACTED]")
67-
print(f" Host: {host}\n\n")
68-
69-
except Exception as e:
70-
print("❌ Authentication failed!")
71-
print(f" Error: {e}")
72-
print("\n Please check your credentials:")
73-
print(" - POSTHOG_PROJECT_API_KEY: Project API key from PostHog settings")
74-
print(
75-
" - POSTHOG_PERSONAL_API_KEY: Personal API key (required for local evaluation)"
76-
)
77-
print(" - POSTHOG_HOST: Your PostHog instance URL")
78-
exit(1)
56+
57+
print("🔑 PostHog Configuration:")
58+
print(f" Project API Key: {project_key[:9]}...")
59+
if local_eval_available:
60+
print(" Personal API Key: [SET]")
61+
else:
62+
print(" Personal API Key: [NOT SET] - Local evaluation examples will be skipped")
63+
print(f" Host: {host}\n")
7964

8065
# Display menu and get user choice
8166
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
8267
print("1. Identify and capture examples")
83-
print("2. Feature flag local evaluation examples")
68+
local_eval_note = "" if local_eval_available else " [requires personal API key]"
69+
print(f"2. Feature flag local evaluation examples{local_eval_note}")
8470
print("3. Feature flag payload examples")
85-
print("4. Flag dependencies examples")
71+
print(f"4. Flag dependencies examples{local_eval_note}")
8672
print("5. Context management and tagging examples")
8773
print("6. Run all examples")
8874
print("7. Exit")
@@ -148,6 +134,14 @@ def load_env_file():
148134
)
149135

150136
elif choice == "2":
137+
if not local_eval_available:
138+
print("\n❌ This example requires a personal API key for local evaluation.")
139+
print(
140+
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
141+
)
142+
posthog.shutdown()
143+
exit(1)
144+
151145
print("\n" + "=" * 60)
152146
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
153147
print("=" * 60)
@@ -215,6 +209,14 @@ def load_env_file():
215209
print(f"Value (variant or enabled): {result.get_value()}")
216210

217211
elif choice == "4":
212+
if not local_eval_available:
213+
print("\n❌ This example requires a personal API key for local evaluation.")
214+
print(
215+
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
216+
)
217+
posthog.shutdown()
218+
exit(1)
219+
218220
print("\n" + "=" * 60)
219221
print("FLAG DEPENDENCIES EXAMPLES")
220222
print("=" * 60)
@@ -429,6 +431,8 @@ def process_payment(payment_id):
429431

430432
elif choice == "6":
431433
print("\n🔄 Running all examples...")
434+
if not local_eval_available:
435+
print(" (Skipping local evaluation examples - no personal API key set)\n")
432436

433437
# Run example 1
434438
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
@@ -447,35 +451,37 @@ def process_payment(payment_id):
447451
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
448452
)
449453

450-
# Run example 2
451-
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
452-
print("🏁 Testing basic feature flags...")
453-
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
454-
print(
455-
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
456-
)
454+
# Run example 2 (requires local evaluation)
455+
if local_eval_available:
456+
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
457+
print("🏁 Testing basic feature flags...")
458+
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
459+
print(
460+
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
461+
)
457462

458463
# Run example 3
459464
print(f"\n{'🔸' * 20} PAYLOADS {'🔸' * 20}")
460465
print("📦 Testing payloads...")
461466
print(f"Payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}")
462467

463-
# Run example 4
464-
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
465-
print("🔗 Testing flag dependencies...")
466-
result1 = posthog.feature_enabled(
467-
"test-flag-dependency",
468-
"demo_user",
469-
person_properties={"email": "user@example.com"},
470-
only_evaluate_locally=True,
471-
)
472-
result2 = posthog.feature_enabled(
473-
"test-flag-dependency",
474-
"demo_user2",
475-
person_properties={"email": "user@other.com"},
476-
only_evaluate_locally=True,
477-
)
478-
print(f"✅ @example.com user: {result1}, regular user: {result2}")
468+
# Run example 4 (requires local evaluation)
469+
if local_eval_available:
470+
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
471+
print("🔗 Testing flag dependencies...")
472+
result1 = posthog.feature_enabled(
473+
"test-flag-dependency",
474+
"demo_user",
475+
person_properties={"email": "user@example.com"},
476+
only_evaluate_locally=True,
477+
)
478+
result2 = posthog.feature_enabled(
479+
"test-flag-dependency",
480+
"demo_user2",
481+
person_properties={"email": "user@other.com"},
482+
only_evaluate_locally=True,
483+
)
484+
print(f"✅ @example.com user: {result1}, regular user: {result2}")
479485

480486
# Run example 5
481487
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")

posthog/request.py

Lines changed: 55 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,20 +73,49 @@ 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.)."""
77+
adapter = HTTPAdapterWithSocketOptions(
78+
max_retries=Retry(
79+
total=2,
80+
connect=2,
81+
read=2,
82+
),
83+
socket_options=socket_options,
84+
)
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 with exponential backoff
99+
(0.5s, 1s delays between retries).
100+
"""
74101
adapter = HTTPAdapterWithSocketOptions(
75102
max_retries=Retry(
76103
total=2,
77104
connect=2,
78105
read=2,
106+
backoff_factor=0.5,
107+
status_forcelist=RETRY_STATUS_FORCELIST,
108+
allowed_methods=["POST"],
79109
),
80110
socket_options=socket_options,
81111
)
82-
session = requests.sessions.Session()
112+
session = requests.Session()
83113
session.mount("https://", adapter)
84114
return session
85115

86116

87117
_session = _build_session()
118+
_flags_session = _build_flags_session()
88119
_socket_options: Optional[SocketOptions] = None
89120
_pooling_enabled = True
90121

@@ -95,6 +126,12 @@ def _get_session() -> requests.Session:
95126
return _build_session(_socket_options)
96127

97128

129+
def _get_flags_session() -> requests.Session:
130+
if _pooling_enabled:
131+
return _flags_session
132+
return _build_flags_session(_socket_options)
133+
134+
98135
def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
99136
"""
100137
Configure socket options for all HTTP connections.
@@ -103,11 +140,12 @@ def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
103140
from posthog import set_socket_options
104141
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
105142
"""
106-
global _session, _socket_options
143+
global _session, _flags_session, _socket_options
107144
if socket_options == _socket_options:
108145
return
109146
_socket_options = socket_options
110147
_session = _build_session(socket_options)
148+
_flags_session = _build_flags_session(socket_options)
111149

112150

113151
def enable_keep_alive() -> None:
@@ -145,6 +183,7 @@ def post(
145183
path=None,
146184
gzip: bool = False,
147185
timeout: int = 15,
186+
session: Optional[requests.Session] = None,
148187
**kwargs,
149188
) -> requests.Response:
150189
"""Post the `kwargs` to the API"""
@@ -165,7 +204,9 @@ def post(
165204
gz.write(data.encode("utf-8"))
166205
data = buf.getvalue()
167206

168-
res = _get_session().post(url, data=data, headers=headers, timeout=timeout)
207+
res = (session or _get_session()).post(
208+
url, data=data, headers=headers, timeout=timeout
209+
)
169210

170211
if res.status_code == 200:
171212
log.debug("data uploaded successfully")
@@ -221,8 +262,16 @@ def flags(
221262
timeout: int = 15,
222263
**kwargs,
223264
) -> Any:
224-
"""Post the `kwargs to the flags API endpoint"""
225-
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
265+
"""Post the kwargs to the flags API endpoint with automatic retries."""
266+
res = post(
267+
api_key,
268+
host,
269+
"/flags/?v=2",
270+
gzip,
271+
timeout,
272+
session=_get_flags_session(),
273+
**kwargs,
274+
)
226275
return _process_response(
227276
res, success_message="Feature flags evaluated successfully"
228277
)

0 commit comments

Comments
 (0)