Skip to content

Commit bdf843a

Browse files
committed
cp
1 parent 7656890 commit bdf843a

7 files changed

Lines changed: 361 additions & 166 deletions

File tree

src/runloop_api_client/_base_client.py

Lines changed: 175 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111
import platform
1212
import warnings
13+
import threading
1314
import email.utils
1415
from types import TracebackType
1516
from random import random
@@ -90,6 +91,14 @@
9091

9192
log: logging.Logger = logging.getLogger(__name__)
9293

94+
# Shared HTTP connection pool state. One pool per client type (sync/async),
95+
# refcounted so the pool is closed when the last SDK client releases it.
96+
_pool_lock = threading.Lock()
97+
_shared_sync_client: httpx.Client | None = None
98+
_shared_sync_refcount: int = 0
99+
_shared_async_client: httpx.AsyncClient | None = None
100+
_shared_async_refcount: int = 0
101+
93102
# TODO: make base page type vars covariant
94103
SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]")
95104
AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]")
@@ -816,6 +825,7 @@ def __init__(self, **kwargs: Any) -> None:
816825
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
817826
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
818827
kwargs.setdefault("follow_redirects", True)
828+
kwargs.setdefault("http2", True)
819829
super().__init__(**kwargs)
820830

821831

@@ -845,6 +855,7 @@ def __del__(self) -> None:
845855
class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]):
846856
_client: httpx.Client
847857
_default_stream_cls: type[Stream[Any]] | None = None
858+
_uses_shared_pool: bool
848859

849860
def __init__(
850861
self,
@@ -857,6 +868,7 @@ def __init__(
857868
custom_headers: Mapping[str, str] | None = None,
858869
custom_query: Mapping[str, object] | None = None,
859870
_strict_response_validation: bool,
871+
shared_http_pool: bool = True,
860872
) -> None:
861873
if not is_given(timeout):
862874
# if the user passed in a custom http client with a non-default
@@ -886,11 +898,28 @@ def __init__(
886898
custom_headers=custom_headers,
887899
_strict_response_validation=_strict_response_validation,
888900
)
889-
self._client = http_client or SyncHttpxClientWrapper(
890-
base_url=base_url,
891-
# cast to a valid type because mypy doesn't understand our type narrowing
892-
timeout=cast(Timeout, timeout),
893-
)
901+
902+
if http_client is not None:
903+
self._client = http_client
904+
self._uses_shared_pool = False
905+
elif shared_http_pool:
906+
global _shared_sync_client, _shared_sync_refcount
907+
with _pool_lock:
908+
if _shared_sync_client is None or _shared_sync_client.is_closed:
909+
_shared_sync_client = SyncHttpxClientWrapper(
910+
base_url=base_url,
911+
timeout=cast(Timeout, timeout),
912+
)
913+
_shared_sync_refcount = 0
914+
_shared_sync_refcount += 1
915+
self._client = _shared_sync_client
916+
self._uses_shared_pool = True
917+
else:
918+
self._client = SyncHttpxClientWrapper(
919+
base_url=base_url,
920+
timeout=cast(Timeout, timeout),
921+
)
922+
self._uses_shared_pool = False
894923

895924
def is_closed(self) -> bool:
896925
return self._client.is_closed
@@ -900,11 +929,40 @@ def close(self) -> None:
900929
901930
The client will *not* be usable after this.
902931
"""
903-
# If an error is thrown while constructing a client, self._client
904-
# may not be present
905-
if hasattr(self, "_client"):
932+
if not hasattr(self, "_client"):
933+
return
934+
if self._uses_shared_pool:
935+
self._release_shared_pool()
936+
else:
906937
self._client.close()
907938

939+
def _release_shared_pool(self) -> None:
940+
global _shared_sync_client, _shared_sync_refcount
941+
with _pool_lock:
942+
if not self._uses_shared_pool:
943+
return
944+
self._uses_shared_pool = False
945+
_shared_sync_refcount -= 1
946+
if _shared_sync_refcount <= 0:
947+
client = _shared_sync_client
948+
_shared_sync_client = None
949+
_shared_sync_refcount = 0
950+
else:
951+
client = None
952+
if client is not None:
953+
try:
954+
client.close()
955+
except Exception:
956+
pass
957+
958+
def __del__(self) -> None:
959+
if not getattr(self, "_uses_shared_pool", False):
960+
return
961+
try:
962+
self._release_shared_pool()
963+
except Exception:
964+
pass
965+
908966
def __enter__(self: _T) -> _T:
909967
return self
910968

@@ -1018,6 +1076,7 @@ def request(
10181076
max_retries=max_retries,
10191077
options=input_options,
10201078
response=None,
1079+
error=err,
10211080
)
10221081
continue
10231082

@@ -1032,6 +1091,7 @@ def request(
10321091
max_retries=max_retries,
10331092
options=input_options,
10341093
response=None,
1094+
error=err,
10351095
)
10361096
continue
10371097

@@ -1083,7 +1143,13 @@ def request(
10831143
)
10841144

10851145
def _sleep_for_retry(
1086-
self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None
1146+
self,
1147+
*,
1148+
retries_taken: int,
1149+
max_retries: int,
1150+
options: FinalRequestOptions,
1151+
response: httpx.Response | None,
1152+
error: BaseException | None = None,
10871153
) -> None:
10881154
remaining_retries = max_retries - retries_taken
10891155
if remaining_retries == 1:
@@ -1092,7 +1158,23 @@ def _sleep_for_retry(
10921158
log.debug("%i retries left", remaining_retries)
10931159

10941160
timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None)
1095-
log.info("Retrying request to %s in %f seconds", options.url, timeout)
1161+
if response is not None:
1162+
log.info(
1163+
"Retrying request to %s in %f seconds (status %d)",
1164+
options.url,
1165+
timeout,
1166+
response.status_code,
1167+
)
1168+
elif error is not None:
1169+
log.info(
1170+
"Retrying request to %s in %f seconds (%s: %s)",
1171+
options.url,
1172+
timeout,
1173+
type(error).__name__,
1174+
error,
1175+
)
1176+
else:
1177+
log.info("Retrying request to %s in %f seconds", options.url, timeout)
10961178

10971179
time.sleep(timeout)
10981180

@@ -1428,6 +1510,7 @@ def __del__(self) -> None:
14281510
class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]):
14291511
_client: httpx.AsyncClient
14301512
_default_stream_cls: type[AsyncStream[Any]] | None = None
1513+
_uses_shared_pool: bool
14311514

14321515
def __init__(
14331516
self,
@@ -1440,6 +1523,7 @@ def __init__(
14401523
http_client: httpx.AsyncClient | None = None,
14411524
custom_headers: Mapping[str, str] | None = None,
14421525
custom_query: Mapping[str, object] | None = None,
1526+
shared_http_pool: bool = True,
14431527
) -> None:
14441528
if not is_given(timeout):
14451529
# if the user passed in a custom http client with a non-default
@@ -1469,11 +1553,28 @@ def __init__(
14691553
custom_headers=custom_headers,
14701554
_strict_response_validation=_strict_response_validation,
14711555
)
1472-
self._client = http_client or AsyncHttpxClientWrapper(
1473-
base_url=base_url,
1474-
# cast to a valid type because mypy doesn't understand our type narrowing
1475-
timeout=cast(Timeout, timeout),
1476-
)
1556+
1557+
if http_client is not None:
1558+
self._client = http_client
1559+
self._uses_shared_pool = False
1560+
elif shared_http_pool:
1561+
global _shared_async_client, _shared_async_refcount
1562+
with _pool_lock:
1563+
if _shared_async_client is None or _shared_async_client.is_closed:
1564+
_shared_async_client = AsyncHttpxClientWrapper(
1565+
base_url=base_url,
1566+
timeout=cast(Timeout, timeout),
1567+
)
1568+
_shared_async_refcount = 0
1569+
_shared_async_refcount += 1
1570+
self._client = _shared_async_client
1571+
self._uses_shared_pool = True
1572+
else:
1573+
self._client = AsyncHttpxClientWrapper(
1574+
base_url=base_url,
1575+
timeout=cast(Timeout, timeout),
1576+
)
1577+
self._uses_shared_pool = False
14771578

14781579
def is_closed(self) -> bool:
14791580
return self._client.is_closed
@@ -1483,7 +1584,39 @@ async def close(self) -> None:
14831584
14841585
The client will *not* be usable after this.
14851586
"""
1486-
await self._client.aclose()
1587+
if self._uses_shared_pool:
1588+
self._release_shared_pool()
1589+
else:
1590+
await self._client.aclose()
1591+
1592+
def _release_shared_pool(self) -> None:
1593+
global _shared_async_client, _shared_async_refcount
1594+
should_close = False
1595+
client = None
1596+
with _pool_lock:
1597+
if not self._uses_shared_pool:
1598+
return
1599+
self._uses_shared_pool = False
1600+
_shared_async_refcount -= 1
1601+
if _shared_async_refcount <= 0:
1602+
client = _shared_async_client
1603+
_shared_async_client = None
1604+
_shared_async_refcount = 0
1605+
should_close = True
1606+
if should_close and client is not None:
1607+
try:
1608+
loop = asyncio.get_running_loop()
1609+
loop.create_task(client.aclose())
1610+
except Exception:
1611+
pass
1612+
1613+
def __del__(self) -> None:
1614+
if not getattr(self, "_uses_shared_pool", False):
1615+
return
1616+
try:
1617+
self._release_shared_pool()
1618+
except Exception:
1619+
pass
14871620

14881621
async def __aenter__(self: _T) -> _T:
14891622
return self
@@ -1603,6 +1736,7 @@ async def request(
16031736
max_retries=max_retries,
16041737
options=input_options,
16051738
response=None,
1739+
error=err,
16061740
)
16071741
continue
16081742

@@ -1617,6 +1751,7 @@ async def request(
16171751
max_retries=max_retries,
16181752
options=input_options,
16191753
response=None,
1754+
error=err,
16201755
)
16211756
continue
16221757

@@ -1668,7 +1803,13 @@ async def request(
16681803
)
16691804

16701805
async def _sleep_for_retry(
1671-
self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None
1806+
self,
1807+
*,
1808+
retries_taken: int,
1809+
max_retries: int,
1810+
options: FinalRequestOptions,
1811+
response: httpx.Response | None,
1812+
error: BaseException | None = None,
16721813
) -> None:
16731814
remaining_retries = max_retries - retries_taken
16741815
if remaining_retries == 1:
@@ -1677,7 +1818,23 @@ async def _sleep_for_retry(
16771818
log.debug("%i retries left", remaining_retries)
16781819

16791820
timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None)
1680-
log.info("Retrying request to %s in %f seconds", options.url, timeout)
1821+
if response is not None:
1822+
log.info(
1823+
"Retrying request to %s in %f seconds (status %d)",
1824+
options.url,
1825+
timeout,
1826+
response.status_code,
1827+
)
1828+
elif error is not None:
1829+
log.info(
1830+
"Retrying request to %s in %f seconds (%s: %s)",
1831+
options.url,
1832+
timeout,
1833+
type(error).__name__,
1834+
error,
1835+
)
1836+
else:
1837+
log.info("Retrying request to %s in %f seconds", options.url, timeout)
16811838

16821839
await anyio.sleep(timeout)
16831840

0 commit comments

Comments
 (0)