Skip to content

Commit 491300d

Browse files
adriangbclaude
andcommitted
Track connection idleness per connection, and cover proxies and Pyodide
Review follow-up, addressing three things. The idle window was tracked on the adapter, behind a lock, and enforced by clearing the whole pool manager. It is now tracked on each pooled connection: a `urllib3` pool subclass stamps the connection on the way in and closes it on the way out if it has sat idle too long, letting `urllib3` reconnect lazily exactly as it does for a connection it detected as dropped. That removes the lock, and with it the risk of a child process inheriting it held across a fork, and it is also more accurate — one session carries traces, metrics and logs, so a steady trace stream would keep a session-level clock from ever firing for the connection that last served a metric export. A proxied request goes through a manager built by `proxy_manager_for`, which `init_poolmanager` never sees, so it had neither keepalive nor recycling. The adapter now applies both there too. The pool classes are derived from whatever the manager already uses, rather than named outright, because a SOCKS proxy manager brings its own. Under Pyodide `urllib3` swaps in an Emscripten connection that talks over `fetch()` and has no `default_socket_options` at all, which broke import. The defaults are now read defensively and every socket option is applied only where the platform has it, which also covers the macOS-only spelling on Linux CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f554ac5 commit 491300d

2 files changed

Lines changed: 140 additions & 29 deletions

File tree

logfire/_internal/http_transport.py

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
Every session Logfire creates therefore gets two measures:
1212
1313
- **TCP keepalive**, so an idle connection keeps producing traffic and the flow is not reclaimed.
14-
- **An idle recycle window**, so a session unused for longer than the window drops its pooled
15-
connections and reconnects rather than gambling on one that may already be dead.
14+
- **An idle recycle window**, so a pooled connection unused for longer than the window is closed
15+
and reconnected rather than gambling on one that may already be dead.
1616
1717
This applies only to sessions Logfire itself creates. Clients belonging to the user are
1818
instrumented, never reconfigured.
@@ -26,13 +26,15 @@
2626

2727
from requests import Session
2828
from requests.adapters import DEFAULT_POOLBLOCK, HTTPAdapter
29-
from urllib3.connection import HTTPConnection
30-
from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
29+
from urllib3 import connection as urllib3_connection
30+
from urllib3.connectionpool import HTTPConnectionPool
31+
from urllib3.poolmanager import PoolManager
3132

3233
_now = time.monotonic
34+
"""Indirection so tests can drive the clock without touching the one `urllib3` itself uses."""
3335

3436
IDLE_CONNECTION_RECYCLE_SECONDS = 30
35-
"""Drop pooled connections when the session has gone unused for longer than this.
37+
"""Close a pooled connection that has sat unused for longer than this.
3638
3739
Chosen to sit below the things that would otherwise reclaim the connection first: the default
3840
60 second metric export interval (`OTEL_METRIC_EXPORT_INTERVAL`), and the idle timeouts of
@@ -54,29 +56,33 @@ def keepalive_socket_options() -> list[tuple[int, int, int | bytes]]:
5456
"""`urllib3`'s default socket options plus TCP keepalive.
5557
5658
Building on `HTTPConnection.default_socket_options` keeps `TCP_NODELAY`, which `urllib3` sets
57-
and which we have no reason to drop.
59+
and which we have no reason to drop. The class is read off the module at call time because
60+
`urllib3` swaps it out under Pyodide, for an Emscripten connection that talks over `fetch()`
61+
and so has neither a socket nor any default options.
5862
"""
59-
options: list[tuple[int, int, int | bytes]] = [
60-
*HTTPConnection.default_socket_options,
61-
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
62-
]
63+
defaults = getattr(urllib3_connection.HTTPConnection, 'default_socket_options', None)
64+
options: list[tuple[int, int, int | bytes]] = [*(defaults or [])]
6365

64-
def add_if_supported(name: str, value: int) -> bool:
66+
def add_if_supported(level: int, name: str, value: int) -> bool:
6567
# Looked up by name because these constants are platform specific: referring to them
6668
# directly would not type check on a platform that lacks them.
6769
option = getattr(socket, name, None)
6870
if option is None:
6971
return False
70-
options.append((socket.IPPROTO_TCP, option, value))
72+
options.append((level, option, value))
7173
return True
7274

75+
if not add_if_supported(socket.SOL_SOCKET, 'SO_KEEPALIVE', 1):
76+
# Nothing to keep alive, so none of the knobs below mean anything either.
77+
return options
78+
7379
# How long a connection may be idle before probing starts. Without this the system default
7480
# applies, which is two hours on most platforms and so useless against a NAT timeout.
7581
# Linux (and recent Windows) call it TCP_KEEPIDLE; macOS calls the same thing TCP_KEEPALIVE.
76-
if not add_if_supported('TCP_KEEPIDLE', TCP_KEEPALIVE_IDLE_SECONDS):
77-
add_if_supported('TCP_KEEPALIVE', TCP_KEEPALIVE_IDLE_SECONDS)
78-
add_if_supported('TCP_KEEPINTVL', TCP_KEEPALIVE_INTERVAL_SECONDS)
79-
add_if_supported('TCP_KEEPCNT', TCP_KEEPALIVE_FAILED_PROBES)
82+
if not add_if_supported(socket.IPPROTO_TCP, 'TCP_KEEPIDLE', TCP_KEEPALIVE_IDLE_SECONDS):
83+
add_if_supported(socket.IPPROTO_TCP, 'TCP_KEEPALIVE', TCP_KEEPALIVE_IDLE_SECONDS)
84+
add_if_supported(socket.IPPROTO_TCP, 'TCP_KEEPINTVL', TCP_KEEPALIVE_INTERVAL_SECONDS)
85+
add_if_supported(socket.IPPROTO_TCP, 'TCP_KEEPCNT', TCP_KEEPALIVE_FAILED_PROBES)
8086

8187
return options
8288

@@ -104,22 +110,32 @@ def _put_conn(self, conn: Any) -> Any:
104110

105111
def _get_conn(self, timeout: float | None = None) -> Any:
106112
conn = super()._get_conn(timeout)
113+
# Absent on a connection this pool has never handed back, i.e. a brand new one.
107114
idle_since = getattr(conn, '_logfire_idle_since', None)
108115
if idle_since is not None and _now() - idle_since > self.idle_recycle_seconds:
109116
conn.close()
110117
return conn
111118

112119

113-
def _pool_classes(idle_recycle_seconds: float) -> dict[str, type[Any]]:
114-
"""Pool classes bound to one recycle window.
120+
def _recycling_pool_class(base: type[HTTPConnectionPool], idle_recycle_seconds: float) -> type[HTTPConnectionPool]:
121+
return type(
122+
f'IdleRecycling{base.__name__}', (_IdleRecyclingPoolMixin, base), {'idle_recycle_seconds': idle_recycle_seconds}
123+
)
124+
125+
126+
def _install_recycling_pools(manager: PoolManager, idle_recycle_seconds: float) -> None:
127+
"""Point a pool manager at recycling versions of the pool classes it already uses.
115128
116-
Built per adapter rather than passed through `connection_pool_kw`, because `urllib3` feeds
117-
that mapping to its pool-key normalizer, which rejects keys it does not know.
129+
Derived from whatever the manager has rather than named outright, because the classes vary:
130+
a SOCKS proxy manager brings its own, and Pyodide swaps in others again. Installed on the
131+
manager rather than passed through `connection_pool_kw`, because `urllib3` feeds that mapping
132+
to its pool-key normalizer, which rejects keys it does not know.
118133
"""
119-
namespace = {'idle_recycle_seconds': idle_recycle_seconds}
120-
return {
121-
'http': type('LogfireHTTPConnectionPool', (_IdleRecyclingPoolMixin, HTTPConnectionPool), namespace),
122-
'https': type('LogfireHTTPSConnectionPool', (_IdleRecyclingPoolMixin, HTTPSConnectionPool), namespace),
134+
manager.pool_classes_by_scheme = {
135+
# `requests` hands back a proxy manager it has already built, so the classes may be ours
136+
# from an earlier call; subclassing again each time would nest them without end.
137+
scheme: cls if issubclass(cls, _IdleRecyclingPoolMixin) else _recycling_pool_class(cls, idle_recycle_seconds)
138+
for scheme, cls in manager.pool_classes_by_scheme.items()
123139
}
124140

125141

@@ -143,9 +159,15 @@ def init_poolmanager(
143159
) -> None:
144160
pool_kwargs.setdefault('socket_options', keepalive_socket_options())
145161
super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
146-
# `getattr` because unpickling restores attributes in an order we do not control.
147-
window = getattr(self, '_idle_recycle_seconds', IDLE_CONNECTION_RECYCLE_SECONDS)
148-
self.poolmanager.pool_classes_by_scheme = _pool_classes(window)
162+
_install_recycling_pools(self.poolmanager, self._idle_recycle_seconds)
163+
164+
def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> Any:
165+
# A proxied request goes through a manager of its own, built here rather than by
166+
# `init_poolmanager`, so the policy has to be applied again as each one appears.
167+
proxy_kwargs.setdefault('socket_options', keepalive_socket_options())
168+
manager = super().proxy_manager_for(proxy, **proxy_kwargs)
169+
_install_recycling_pools(manager, self._idle_recycle_seconds)
170+
return manager
149171

150172

151173
def install_connection_policy(session: Session, *, idle_recycle_seconds: float | None = None) -> None:

tests/test_http_transport.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import pytest
99
import requests
1010
from urllib3.connection import HTTPConnection
11+
from urllib3.connectionpool import HTTPSConnectionPool
12+
from urllib3.poolmanager import PoolManager
1113

1214
from logfire._internal.auth import UserToken
1315
from logfire._internal.client import LogfireClient
@@ -18,7 +20,8 @@
1820
TCP_KEEPALIVE_IDLE_SECONDS,
1921
LogfireHTTPAdapter,
2022
_IdleRecyclingPoolMixin, # pyright: ignore[reportPrivateUsage]
21-
_pool_classes, # pyright: ignore[reportPrivateUsage]
23+
_install_recycling_pools, # pyright: ignore[reportPrivateUsage]
24+
_recycling_pool_class, # pyright: ignore[reportPrivateUsage]
2225
install_connection_policy,
2326
keepalive_socket_options,
2427
)
@@ -44,6 +47,43 @@ def test_keepalive_idle_option_is_set_on_platforms_that_have_one() -> None:
4447
assert values == [TCP_KEEPALIVE_IDLE_SECONDS]
4548

4649

50+
def test_the_macos_spelling_of_the_idle_option_is_used_when_it_is_the_only_one(
51+
monkeypatch: pytest.MonkeyPatch,
52+
) -> None:
53+
"""Linux calls it TCP_KEEPIDLE, macOS calls the same thing TCP_KEEPALIVE."""
54+
monkeypatch.delattr(socket, 'TCP_KEEPIDLE', raising=False)
55+
monkeypatch.setattr(socket, 'TCP_KEEPALIVE', 0x10, raising=False)
56+
57+
assert (socket.IPPROTO_TCP, 0x10, TCP_KEEPALIVE_IDLE_SECONDS) in keepalive_socket_options()
58+
59+
60+
def test_a_platform_missing_an_option_still_gets_the_rest(monkeypatch: pytest.MonkeyPatch) -> None:
61+
for name in ('TCP_KEEPIDLE', 'TCP_KEEPALIVE', 'TCP_KEEPINTVL', 'TCP_KEEPCNT'):
62+
monkeypatch.delattr(socket, name, raising=False)
63+
64+
assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in keepalive_socket_options()
65+
66+
67+
def test_a_platform_without_keepalive_leaves_the_defaults_alone(monkeypatch: pytest.MonkeyPatch) -> None:
68+
monkeypatch.delattr(socket, 'SO_KEEPALIVE', raising=False)
69+
70+
assert keepalive_socket_options() == list(HTTPConnection.default_socket_options)
71+
72+
73+
def test_a_connection_class_without_default_socket_options(monkeypatch: pytest.MonkeyPatch) -> None:
74+
"""Under Pyodide `urllib3` swaps in an Emscripten connection that has no defaults at all."""
75+
76+
class EmscriptenLikeConnection:
77+
pass
78+
79+
monkeypatch.setattr('urllib3.connection.HTTPConnection', EmscriptenLikeConnection)
80+
options = keepalive_socket_options()
81+
82+
assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in options
83+
for default in HTTPConnection.default_socket_options:
84+
assert default not in options
85+
86+
4787
def test_adapter_passes_socket_options_to_the_pool() -> None:
4888
adapter = LogfireHTTPAdapter()
4989
assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in adapter.poolmanager.connection_pool_kw['socket_options']
@@ -79,7 +119,7 @@ def not_dropped(conn: Any) -> bool:
79119

80120
# Mock connections are not real sockets, so urllib3's own liveness check must stand aside.
81121
monkeypatch.setattr('urllib3.connectionpool.is_connection_dropped', not_dropped)
82-
pool = _pool_classes(idle_recycle_seconds)['https']('example.com', maxsize=5)
122+
pool: Any = _recycling_pool_class(HTTPSConnectionPool, idle_recycle_seconds)('example.com', maxsize=5)
83123
# urllib3 pre-fills the pool with `None` placeholders; drain them so seeded connections are
84124
# not discarded as "pool is full".
85125
while not pool.pool.empty():
@@ -148,6 +188,54 @@ def test_recycle_window_is_configurable(monkeypatch: pytest.MonkeyPatch, clock:
148188
conn.close.assert_called_once()
149189

150190

191+
def test_connections_urllib3_never_pooled_are_passed_through(pool: Any) -> None:
192+
"""`urllib3` puts `None` back after a failed request, and builds fresh connections lazily."""
193+
pool._put_conn(None)
194+
195+
conn = pool._get_conn()
196+
197+
assert not hasattr(conn, '_logfire_idle_since')
198+
199+
200+
def test_recycling_pools_are_derived_from_the_classes_the_manager_already_uses() -> None:
201+
"""A SOCKS proxy manager brings pool classes of its own, which must not be replaced."""
202+
203+
class CustomPool(HTTPSConnectionPool):
204+
pass
205+
206+
manager = PoolManager()
207+
manager.pool_classes_by_scheme = {'https': CustomPool} # pyright: ignore[reportAttributeAccessIssue]
208+
209+
_install_recycling_pools(manager, IDLE_CONNECTION_RECYCLE_SECONDS)
210+
211+
installed = manager.pool_classes_by_scheme['https']
212+
assert issubclass(installed, CustomPool)
213+
assert issubclass(installed, _IdleRecyclingPoolMixin)
214+
215+
216+
def test_proxied_requests_get_the_policy_too() -> None:
217+
"""`requests` builds a separate manager per proxy, which `init_poolmanager` never sees."""
218+
adapter = LogfireHTTPAdapter(idle_recycle_seconds=13)
219+
220+
manager = adapter.proxy_manager_for('http://proxy.example.com')
221+
222+
assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in manager.connection_pool_kw['socket_options']
223+
pool_class = manager.pool_classes_by_scheme['https']
224+
assert issubclass(pool_class, _IdleRecyclingPoolMixin)
225+
assert pool_class.idle_recycle_seconds == 13
226+
227+
228+
def test_a_reused_proxy_manager_is_not_wrapped_again() -> None:
229+
"""`requests` caches proxy managers, so re-applying the policy must be a no-op."""
230+
adapter = LogfireHTTPAdapter()
231+
proxy = 'http://proxy.example.com'
232+
233+
first = adapter.proxy_manager_for(proxy).pool_classes_by_scheme['https']
234+
second = adapter.proxy_manager_for(proxy).pool_classes_by_scheme['https']
235+
236+
assert first is second
237+
238+
151239
def test_install_connection_policy_mounts_both_schemes() -> None:
152240
session = requests.Session()
153241
install_connection_policy(session)
@@ -163,6 +251,7 @@ def test_adapter_survives_pickling() -> None:
163251
restored = pickle.loads(pickle.dumps(LogfireHTTPAdapter(idle_recycle_seconds=7)))
164252

165253
assert isinstance(restored, LogfireHTTPAdapter)
254+
assert restored._idle_recycle_seconds == 7 # pyright: ignore[reportPrivateUsage]
166255
assert restored.poolmanager.pool_classes_by_scheme['https'].idle_recycle_seconds == 7
167256

168257

0 commit comments

Comments
 (0)