Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
# 7.4.0 - 2025-12-16

feat: Add automatic retries for feature flag requests

Feature flag API requests now automatically retry on transient failures:
- Network errors (connection refused, DNS failures, timeouts)
- Server errors (500, 502, 503, 504)
- Up to 2 retries with exponential backoff (0.5s, 1s delays)

Rate limit (429) and quota (402) errors are not retried.

# 7.3.1 - 2025-12-06

fix: remove unused $exception_message and $exception_type
Expand Down
128 changes: 67 additions & 61 deletions example.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,54 +35,40 @@ def load_env_file():
personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "")
host = os.getenv("POSTHOG_HOST", "http://localhost:8000")

# Check if credentials are provided
if not project_key or not personal_api_key:
print("❌ Missing PostHog credentials!")
print(
" Please set POSTHOG_PROJECT_API_KEY and POSTHOG_PERSONAL_API_KEY environment variables"
)
# Check if project key is provided (required)
if not project_key:
print("❌ Missing PostHog project API key!")
print(" Please set POSTHOG_PROJECT_API_KEY environment variable")
print(" or copy .env.example to .env and fill in your values")
exit(1)

# Test authentication before proceeding
print("🔑 Testing PostHog authentication...")
# Configure PostHog with credentials
posthog.debug = False
posthog.api_key = project_key
posthog.project_api_key = project_key
posthog.host = host
posthog.poll_interval = 10

try:
# Configure PostHog with credentials
posthog.debug = False # Keep quiet during auth test
posthog.api_key = project_key
posthog.project_api_key = project_key
# Check if personal API key is available for local evaluation
local_eval_available = bool(personal_api_key)
if personal_api_key:
posthog.personal_api_key = personal_api_key
posthog.host = host
posthog.poll_interval = 10

# Test by attempting to get feature flags (this validates both keys)
# This will fail if credentials are invalid
test_flags = posthog.get_all_flags("test_user", only_evaluate_locally=True)

# If we get here without exception, credentials work
print("✅ Authentication successful!")
print(f" Project API Key: {project_key[:9]}...")
print(" Personal API Key: [REDACTED]")
print(f" Host: {host}\n\n")

except Exception as e:
print("❌ Authentication failed!")
print(f" Error: {e}")
print("\n Please check your credentials:")
print(" - POSTHOG_PROJECT_API_KEY: Project API key from PostHog settings")
print(
" - POSTHOG_PERSONAL_API_KEY: Personal API key (required for local evaluation)"
)
print(" - POSTHOG_HOST: Your PostHog instance URL")
exit(1)

print("🔑 PostHog Configuration:")
print(f" Project API Key: {project_key[:9]}...")
if local_eval_available:
print(" Personal API Key: [SET]")
else:
print(" Personal API Key: [NOT SET] - Local evaluation examples will be skipped")
print(f" Host: {host}\n")

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

elif choice == "2":
if not local_eval_available:
print("\n❌ This example requires a personal API key for local evaluation.")
print(
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
posthog.shutdown()
exit(1)

print("\n" + "=" * 60)
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
print("=" * 60)
Expand Down Expand Up @@ -215,6 +209,14 @@ def load_env_file():
print(f"Value (variant or enabled): {result.get_value()}")

elif choice == "4":
if not local_eval_available:
print("\n❌ This example requires a personal API key for local evaluation.")
print(
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
posthog.shutdown()
exit(1)

print("\n" + "=" * 60)
print("FLAG DEPENDENCIES EXAMPLES")
print("=" * 60)
Expand Down Expand Up @@ -429,6 +431,8 @@ def process_payment(payment_id):

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

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

# Run example 2
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
print("🏁 Testing basic feature flags...")
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
print(
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
# Run example 2 (requires local evaluation)
if local_eval_available:
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
print("🏁 Testing basic feature flags...")
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
print(
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)

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

# Run example 4
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
print("🔗 Testing flag dependencies...")
result1 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
result2 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user2",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
print(f"✅ @example.com user: {result1}, regular user: {result2}")
# Run example 4 (requires local evaluation)
if local_eval_available:
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
print("🔗 Testing flag dependencies...")
result1 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
result2 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user2",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
print(f"✅ @example.com user: {result1}, regular user: {result2}")

# Run example 5
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
Expand Down
61 changes: 55 additions & 6 deletions posthog/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from io import BytesIO
from typing import Any, List, Optional, Tuple, Union


import requests
from dateutil.tz import tzutc
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
Expand Down Expand Up @@ -42,6 +41,9 @@
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]


def _mask_tokens_in_url(url: str) -> str:
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
Expand Down Expand Up @@ -71,20 +73,49 @@ def init_poolmanager(self, *args, **kwargs):


def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
"""Build a session for general requests (batch, decide, etc.)."""
adapter = HTTPAdapterWithSocketOptions(
max_retries=Retry(
total=2,
connect=2,
read=2,
),
socket_options=socket_options,
)
session = requests.Session()
session.mount("https://", adapter)
return session


def _build_flags_session(
socket_options: Optional[SocketOptions] = None,
) -> requests.Session:
"""
Build a session for feature flag requests with POST retries.

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).
"""
adapter = HTTPAdapterWithSocketOptions(
max_retries=Retry(
total=2,
connect=2,
read=2,
backoff_factor=0.5,
status_forcelist=RETRY_STATUS_FORCELIST,
allowed_methods=["POST"],
),
socket_options=socket_options,
)
session = requests.sessions.Session()
session = requests.Session()
session.mount("https://", adapter)
return session


_session = _build_session()
_flags_session = _build_flags_session()
_socket_options: Optional[SocketOptions] = None
_pooling_enabled = True

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


def _get_flags_session() -> requests.Session:
if _pooling_enabled:
return _flags_session
return _build_flags_session(_socket_options)


def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
"""
Configure socket options for all HTTP connections.
Expand All @@ -103,11 +140,12 @@ def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
from posthog import set_socket_options
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
"""
global _session, _socket_options
global _session, _flags_session, _socket_options
if socket_options == _socket_options:
return
_socket_options = socket_options
_session = _build_session(socket_options)
_flags_session = _build_flags_session(socket_options)


def enable_keep_alive() -> None:
Expand Down Expand Up @@ -145,6 +183,7 @@ def post(
path=None,
gzip: bool = False,
timeout: int = 15,
session: Optional[requests.Session] = None,
**kwargs,
) -> requests.Response:
"""Post the `kwargs` to the API"""
Expand All @@ -165,7 +204,9 @@ def post(
gz.write(data.encode("utf-8"))
data = buf.getvalue()

res = _get_session().post(url, data=data, headers=headers, timeout=timeout)
res = (session or _get_session()).post(
url, data=data, headers=headers, timeout=timeout
)

if res.status_code == 200:
log.debug("data uploaded successfully")
Expand Down Expand Up @@ -221,8 +262,16 @@ def flags(
timeout: int = 15,
**kwargs,
) -> Any:
"""Post the `kwargs to the flags API endpoint"""
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
"""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"
)
Expand Down
Loading
Loading