1111Every 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
1717This applies only to sessions Logfire itself creates. Clients belonging to the user are
1818instrumented, never reconfigured.
2626
2727from requests import Session
2828from 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
3436IDLE_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
3739Chosen to sit below the things that would otherwise reclaim the connection first: the default
384060 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
151173def install_connection_policy (session : Session , * , idle_recycle_seconds : float | None = None ) -> None :
0 commit comments