1010import logging
1111import platform
1212import warnings
13+ import threading
1314import email .utils
1415from types import TracebackType
1516from random import random
9091
9192log : 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
94103SyncPageT = TypeVar ("SyncPageT" , bound = "BaseSyncPage[Any]" )
95104AsyncPageT = 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:
845855class 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:
14281510class 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