Skip to content

Commit 000479a

Browse files
jrvb-rlclaude
andcommitted
Share transports instead of clients to fix pool bugs
Fixes three bugs in the shared HTTP pool: (1) async clients created without a running event loop leaked connections, (2) __del__ could drop clients without closing them, (3) sharing httpx.Client shared cookie jars across SDK instances. Now shares only the transport (connection pool) via refcounted wrappers, giving each SDK instance its own httpx client with isolated mutable state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8c24dd0 commit 000479a

3 files changed

Lines changed: 218 additions & 200 deletions

File tree

src/runloop_api_client/_base_client.py

Lines changed: 116 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -92,24 +92,87 @@
9292

9393
log: 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
115178
SyncPageT = 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

tests/test_client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
DefaultHttpxClient,
3333
DefaultAsyncHttpxClient,
3434
get_platform,
35+
_SharedTransport,
3536
make_request_options,
37+
_SharedAsyncTransport,
3638
)
3739

3840
from .utils import update_env
@@ -105,7 +107,9 @@ async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter]
105107

106108
def _get_open_connections(client: Runloop | AsyncRunloop) -> int:
107109
transport = client._client._transport
108-
assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport)
110+
if isinstance(transport, (_SharedTransport, _SharedAsyncTransport)):
111+
transport = transport._transport
112+
assert isinstance(transport, (httpx.HTTPTransport, httpx.AsyncHTTPTransport))
109113

110114
pool = transport._pool
111115
return len(pool._requests)

0 commit comments

Comments
 (0)