Skip to content

Commit 3eb1610

Browse files
committed
feat: Allow customization of socket options
This allows clients to configure (e.g.) socket keep alive probes
1 parent ce38fb2 commit 3eb1610

File tree

3 files changed

+98
-11
lines changed

3 files changed

+98
-11
lines changed

posthog/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
InconclusiveMatchError as InconclusiveMatchError,
2323
RequiresServerEvaluation as RequiresServerEvaluation,
2424
)
25+
from posthog.request import (
26+
enable_keep_alive as enable_keep_alive,
27+
set_socket_options as set_socket_options,
28+
SocketOptions as SocketOptions,
29+
)
2530
from posthog.types import (
2631
FeatureFlag,
2732
FlagsAndPayloads,

posthog/request.py

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,47 @@
11
import json
22
import logging
33
import re
4+
import socket
45
from dataclasses import dataclass
56
from datetime import date, datetime
67
from gzip import GzipFile
78
from io import BytesIO
8-
from typing import Any, Optional, Union
9+
from typing import Any, List, Optional, Tuple, Union
10+
911

1012
import requests
1113
from dateutil.tz import tzutc
14+
from requests.adapters import HTTPAdapter
15+
from urllib3.connection import HTTPConnection
1216
from urllib3.util.retry import Retry
1317

1418
from posthog.utils import remove_trailing_slash
1519
from posthog.version import VERSION
1620

21+
SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
22+
23+
KEEPALIVE_IDLE_SECONDS = 60
24+
KEEPALIVE_INTERVAL_SECONDS = 60
25+
KEEPALIVE_PROBE_COUNT = 3
26+
27+
# TCP keepalive probes idle connections to prevent them from being dropped.
28+
# SO_KEEPALIVE is cross-platform, but timing options vary:
29+
# - Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
30+
# - macOS: only SO_KEEPALIVE (uses system defaults)
31+
# - Windows: TCP_KEEPIDLE, TCP_KEEPINTVL (since Windows 10 1709)
32+
KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list(
33+
HTTPConnection.default_socket_options
34+
) + [
35+
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
36+
]
37+
for attr, value in [
38+
("TCP_KEEPIDLE", KEEPALIVE_IDLE_SECONDS),
39+
("TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS),
40+
("TCP_KEEPCNT", KEEPALIVE_PROBE_COUNT),
41+
]:
42+
if hasattr(socket, attr):
43+
KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
44+
1745

1846
def _mask_tokens_in_url(url: str) -> str:
1947
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
@@ -29,17 +57,52 @@ class GetResponse:
2957
not_modified: bool = False
3058

3159

32-
# Retry on both connect and read errors
33-
# by default read errors will only retry idempotent HTTP methods (so not POST)
34-
adapter = requests.adapters.HTTPAdapter(
35-
max_retries=Retry(
36-
total=2,
37-
connect=2,
38-
read=2,
60+
class HTTPAdapterWithSocketOptions(HTTPAdapter):
61+
"""HTTPAdapter with configurable socket options."""
62+
63+
def __init__(self, *args, socket_options: Optional[SocketOptions] = None, **kwargs):
64+
self.socket_options = socket_options
65+
super().__init__(*args, **kwargs)
66+
67+
def init_poolmanager(self, *args, **kwargs):
68+
if self.socket_options is not None:
69+
kwargs["socket_options"] = self.socket_options
70+
super().init_poolmanager(*args, **kwargs)
71+
72+
73+
def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
74+
adapter = HTTPAdapterWithSocketOptions(
75+
max_retries=Retry(
76+
total=2,
77+
connect=2,
78+
read=2,
79+
),
80+
socket_options=socket_options,
3981
)
40-
)
41-
_session = requests.sessions.Session()
42-
_session.mount("https://", adapter)
82+
session = requests.sessions.Session()
83+
session.mount("https://", adapter)
84+
return session
85+
86+
87+
_session = _build_session()
88+
89+
90+
def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
91+
"""
92+
Configure socket options for all HTTP connections.
93+
94+
Example:
95+
from posthog import set_socket_options
96+
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
97+
"""
98+
global _session
99+
_session = _build_session(socket_options)
100+
101+
102+
def enable_keep_alive() -> None:
103+
"""Enable TCP keepalive to prevent idle connections from being dropped."""
104+
set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS)
105+
43106

44107
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
45108
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"

posthog/test/test_request.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010
APIError,
1111
DatetimeSerializer,
1212
GetResponse,
13+
KEEP_ALIVE_SOCKET_OPTIONS,
1314
QuotaLimitError,
1415
_mask_tokens_in_url,
1516
batch_post,
1617
decide,
1718
determine_server_host,
19+
enable_keep_alive,
1820
get,
21+
set_socket_options,
1922
)
2023
from posthog.test.test_utils import TEST_API_KEY
2124

@@ -344,3 +347,19 @@ def test_get_removes_trailing_slash_from_host(self, mock_get):
344347
)
345348
def test_routing_to_custom_host(host, expected):
346349
assert determine_server_host(host) == expected
350+
351+
352+
def test_enable_keep_alive_sets_socket_options():
353+
enable_keep_alive()
354+
from posthog.request import _session
355+
356+
adapter = _session.get_adapter("https://example.com")
357+
assert adapter.socket_options == KEEP_ALIVE_SOCKET_OPTIONS
358+
359+
360+
def test_set_socket_options_clears_with_none():
361+
set_socket_options(None)
362+
from posthog.request import _session
363+
364+
adapter = _session.get_adapter("https://example.com")
365+
assert adapter.socket_options is None

0 commit comments

Comments
 (0)