9292
9393log : logging .Logger = logging .getLogger (__name__ )
9494
95- # Shared HTTP connection pool state. One pool per client type (sync/async),
96- # refcounted so the pool is closed when the last SDK client releases it.
97- # The async pool is keyed by event loop because httpx.AsyncClient binds to
98- # the loop that created it and cannot be reused across asyncio.run() calls.
95+ # Shared HTTP transport state. We share transports (connection pools) rather
96+ # than full httpx clients so each SDK instance keeps its own cookie jar and
97+ # mutable client state. Refcounted wrappers close the real transport only
98+ # when the last user releases it.
99+ # The async transport is keyed by event loop because connections bind to the
100+ # loop that created them and cannot be reused across asyncio.run() calls.
99101_pool_lock = threading .Lock ()
100- _shared_sync_client : httpx .Client | None = None
101- _shared_sync_refcount : int = 0
102102
103103
104- class _AsyncPoolState :
105- __slots__ = ( "client" , " refcount" )
104+ class _SharedTransport ( httpx . BaseTransport ) :
105+ """Refcounted wrapper: delegates to a real transport, closes it when refcount hits 0."""
106106
107- def __init__ (self , client : httpx .AsyncClient ) -> None :
108- self .client = client
109- self .refcount = 1
107+ def __init__ (self , transport : httpx .BaseTransport ) -> None :
108+ self ._transport = transport
109+ self ._refcount = 1
110+ self ._lock = threading .Lock ()
111+
112+ @property
113+ def refcount (self ) -> int :
114+ return self ._refcount
115+
116+ def acquire (self ) -> bool :
117+ with self ._lock :
118+ if self ._refcount <= 0 :
119+ return False
120+ self ._refcount += 1
121+ return True
122+
123+ @override
124+ def handle_request (self , request : httpx .Request ) -> httpx .Response :
125+ return self ._transport .handle_request (request )
126+
127+ @override
128+ def close (self ) -> None :
129+ should_close = False
130+ with self ._lock :
131+ self ._refcount -= 1
132+ if self ._refcount <= 0 :
133+ should_close = True
134+ if should_close :
135+ self ._transport .close ()
110136
111137
112- _async_pools : weakref .WeakKeyDictionary [asyncio .AbstractEventLoop , _AsyncPoolState ] = weakref .WeakKeyDictionary ()
138+ class _SharedAsyncTransport (httpx .AsyncBaseTransport ):
139+ """Async refcounted wrapper: delegates to a real async transport."""
140+
141+ def __init__ (self , transport : httpx .AsyncBaseTransport ) -> None :
142+ self ._transport = transport
143+ self ._refcount = 1
144+ self ._lock = threading .Lock ()
145+
146+ @property
147+ def refcount (self ) -> int :
148+ return self ._refcount
149+
150+ def acquire (self ) -> bool :
151+ with self ._lock :
152+ if self ._refcount <= 0 :
153+ return False
154+ self ._refcount += 1
155+ return True
156+
157+ @override
158+ async def handle_async_request (self , request : httpx .Request ) -> httpx .Response :
159+ return await self ._transport .handle_async_request (request )
160+
161+ @override
162+ async def aclose (self ) -> None :
163+ should_close = False
164+ with self ._lock :
165+ self ._refcount -= 1
166+ if self ._refcount <= 0 :
167+ should_close = True
168+ if should_close :
169+ await self ._transport .aclose ()
170+
171+
172+ _shared_sync_transport : _SharedTransport | None = None
173+ _shared_async_transports : weakref .WeakKeyDictionary [asyncio .AbstractEventLoop , _SharedAsyncTransport ] = (
174+ weakref .WeakKeyDictionary ()
175+ )
113176
114177# TODO: make base page type vars covariant
115178SyncPageT = TypeVar ("SyncPageT" , bound = "BaseSyncPage[Any]" )
@@ -918,16 +981,17 @@ def __init__(
918981 self ._client = http_client
919982 self ._uses_shared_pool = False
920983 elif shared_http_pool :
921- global _shared_sync_client , _shared_sync_refcount
984+ global _shared_sync_transport
922985 with _pool_lock :
923- if _shared_sync_client is None or _shared_sync_client .is_closed :
924- _shared_sync_client = SyncHttpxClientWrapper (
925- base_url = base_url ,
926- timeout = cast (Timeout , timeout ),
986+ if _shared_sync_transport is None or not _shared_sync_transport .acquire ():
987+ _shared_sync_transport = _SharedTransport (
988+ httpx .HTTPTransport (limits = DEFAULT_CONNECTION_LIMITS , http2 = True ),
927989 )
928- _shared_sync_refcount = 0
929- _shared_sync_refcount += 1
930- self ._client = _shared_sync_client
990+ self ._client = SyncHttpxClientWrapper (
991+ base_url = base_url ,
992+ timeout = cast (Timeout , timeout ),
993+ transport = _shared_sync_transport ,
994+ )
931995 self ._uses_shared_pool = True
932996 else :
933997 self ._client = SyncHttpxClientWrapper (
@@ -946,38 +1010,10 @@ def close(self) -> None:
9461010 """
9471011 if not hasattr (self , "_client" ):
9481012 return
949- self ._closed = True
950- if self ._uses_shared_pool :
951- self ._release_shared_pool ()
952- else :
953- self ._client .close ()
954-
955- def _release_shared_pool (self ) -> None :
956- global _shared_sync_client , _shared_sync_refcount
957- with _pool_lock :
958- if not self ._uses_shared_pool :
959- return
960- self ._uses_shared_pool = False
961- _shared_sync_refcount -= 1
962- if _shared_sync_refcount <= 0 :
963- client = _shared_sync_client
964- _shared_sync_client = None
965- _shared_sync_refcount = 0
966- else :
967- client = None
968- if client is not None :
969- try :
970- client .close ()
971- except Exception :
972- pass
973-
974- def __del__ (self ) -> None :
975- if not getattr (self , "_uses_shared_pool" , False ):
1013+ if self ._closed :
9761014 return
977- try :
978- self ._release_shared_pool ()
979- except Exception :
980- pass
1015+ self ._closed = True
1016+ self ._client .close ()
9811017
9821018 def __enter__ (self : _T ) -> _T :
9831019 return self
@@ -1528,7 +1564,6 @@ class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]):
15281564 _default_stream_cls : type [AsyncStream [Any ]] | None = None
15291565 _uses_shared_pool : bool
15301566 _closed : bool
1531- _pool_loop : asyncio .AbstractEventLoop | None
15321567
15331568 def __init__ (
15341569 self ,
@@ -1573,39 +1608,44 @@ def __init__(
15731608 )
15741609
15751610 self ._closed = False
1576- self ._pool_loop = None
15771611
15781612 if http_client is not None :
15791613 self ._client = http_client
15801614 self ._uses_shared_pool = False
15811615 elif shared_http_pool :
1582- self ._acquire_shared_pool (base_url , cast (Timeout , timeout ))
1616+ try :
1617+ loop : asyncio .AbstractEventLoop | None = asyncio .get_running_loop ()
1618+ except RuntimeError :
1619+ loop = None
1620+ if loop is not None :
1621+ with _pool_lock :
1622+ existing = _shared_async_transports .get (loop )
1623+ if existing is not None and existing .acquire ():
1624+ transport : _SharedAsyncTransport = existing
1625+ else :
1626+ transport = _SharedAsyncTransport (
1627+ httpx .AsyncHTTPTransport (limits = DEFAULT_CONNECTION_LIMITS , http2 = True ),
1628+ )
1629+ _shared_async_transports [loop ] = transport
1630+ self ._client = AsyncHttpxClientWrapper (
1631+ base_url = base_url ,
1632+ timeout = cast (Timeout , timeout ),
1633+ transport = transport ,
1634+ )
1635+ self ._uses_shared_pool = True
1636+ else :
1637+ self ._client = AsyncHttpxClientWrapper (
1638+ base_url = base_url ,
1639+ timeout = cast (Timeout , timeout ),
1640+ )
1641+ self ._uses_shared_pool = False
15831642 else :
15841643 self ._client = AsyncHttpxClientWrapper (
15851644 base_url = base_url ,
15861645 timeout = cast (Timeout , timeout ),
15871646 )
15881647 self ._uses_shared_pool = False
15891648
1590- def _acquire_shared_pool (self , base_url : str | URL , timeout : Timeout ) -> None :
1591- try :
1592- loop = asyncio .get_running_loop ()
1593- except RuntimeError :
1594- loop = None
1595- with _pool_lock :
1596- pool_state = _async_pools .get (loop ) if loop is not None else None
1597- if pool_state is None or pool_state .client .is_closed :
1598- pool_state = _AsyncPoolState (
1599- AsyncHttpxClientWrapper (base_url = base_url , timeout = timeout ),
1600- )
1601- if loop is not None :
1602- _async_pools [loop ] = pool_state
1603- else :
1604- pool_state .refcount += 1
1605- self ._client = pool_state .client
1606- self ._pool_loop = loop
1607- self ._uses_shared_pool = True
1608-
16091649 def is_closed (self ) -> bool :
16101650 return self ._closed or self ._client .is_closed
16111651
@@ -1616,63 +1656,10 @@ async def close(self) -> None:
16161656 """
16171657 if not hasattr (self , "_client" ):
16181658 return
1619- self ._closed = True
1620- if self ._uses_shared_pool :
1621- await self ._release_shared_pool ()
1622- else :
1623- await self ._client .aclose ()
1624-
1625- async def _release_shared_pool (self ) -> None :
1626- client : httpx .AsyncClient | None = None
1627- with _pool_lock :
1628- if not self ._uses_shared_pool :
1629- return
1630- self ._uses_shared_pool = False
1631- loop = self ._pool_loop
1632- if loop is None :
1633- return
1634- pool = _async_pools .get (loop )
1635- if pool is not None :
1636- pool .refcount -= 1
1637- if pool .refcount <= 0 :
1638- client = pool .client
1639- del _async_pools [loop ]
1640- if client is not None :
1641- try :
1642- await client .aclose ()
1643- except Exception :
1644- pass
1645-
1646- def _release_shared_pool_sync (self ) -> None :
1647- """Best-effort synchronous release for use in __del__."""
1648- client : httpx .AsyncClient | None = None
1649- with _pool_lock :
1650- if not self ._uses_shared_pool :
1651- return
1652- self ._uses_shared_pool = False
1653- loop = self ._pool_loop
1654- if loop is None :
1655- return
1656- pool = _async_pools .get (loop )
1657- if pool is not None :
1658- pool .refcount -= 1
1659- if pool .refcount <= 0 :
1660- client = pool .client
1661- del _async_pools [loop ]
1662- if client is not None :
1663- try :
1664- running_loop = asyncio .get_running_loop ()
1665- running_loop .create_task (client .aclose ())
1666- except Exception :
1667- pass
1668-
1669- def __del__ (self ) -> None :
1670- if not getattr (self , "_uses_shared_pool" , False ):
1659+ if self ._closed :
16711660 return
1672- try :
1673- self ._release_shared_pool_sync ()
1674- except Exception :
1675- pass
1661+ self ._closed = True
1662+ await self ._client .aclose ()
16761663
16771664 async def __aenter__ (self : _T ) -> _T :
16781665 return self
0 commit comments