connection_pool.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. from __future__ import annotations
  2. import ssl
  3. import sys
  4. import types
  5. import typing
  6. from .._backends.auto import AutoBackend
  7. from .._backends.base import SOCKET_OPTION, AsyncNetworkBackend
  8. from .._exceptions import ConnectionNotAvailable, UnsupportedProtocol
  9. from .._models import Origin, Proxy, Request, Response
  10. from .._synchronization import AsyncEvent, AsyncShieldCancellation, AsyncThreadLock
  11. from .connection import AsyncHTTPConnection
  12. from .interfaces import AsyncConnectionInterface, AsyncRequestInterface
  13. class AsyncPoolRequest:
  14. def __init__(self, request: Request) -> None:
  15. self.request = request
  16. self.connection: AsyncConnectionInterface | None = None
  17. self._connection_acquired = AsyncEvent()
  18. def assign_to_connection(self, connection: AsyncConnectionInterface | None) -> None:
  19. self.connection = connection
  20. self._connection_acquired.set()
  21. def clear_connection(self) -> None:
  22. self.connection = None
  23. self._connection_acquired = AsyncEvent()
  24. async def wait_for_connection(
  25. self, timeout: float | None = None
  26. ) -> AsyncConnectionInterface:
  27. if self.connection is None:
  28. await self._connection_acquired.wait(timeout=timeout)
  29. assert self.connection is not None
  30. return self.connection
  31. def is_queued(self) -> bool:
  32. return self.connection is None
  33. class AsyncConnectionPool(AsyncRequestInterface):
  34. """
  35. A connection pool for making HTTP requests.
  36. """
  37. def __init__(
  38. self,
  39. ssl_context: ssl.SSLContext | None = None,
  40. proxy: Proxy | None = None,
  41. max_connections: int | None = 10,
  42. max_keepalive_connections: int | None = None,
  43. keepalive_expiry: float | None = None,
  44. http1: bool = True,
  45. http2: bool = False,
  46. retries: int = 0,
  47. local_address: str | None = None,
  48. uds: str | None = None,
  49. network_backend: AsyncNetworkBackend | None = None,
  50. socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
  51. ) -> None:
  52. """
  53. A connection pool for making HTTP requests.
  54. Parameters:
  55. ssl_context: An SSL context to use for verifying connections.
  56. If not specified, the default `httpcore.default_ssl_context()`
  57. will be used.
  58. max_connections: The maximum number of concurrent HTTP connections that
  59. the pool should allow. Any attempt to send a request on a pool that
  60. would exceed this amount will block until a connection is available.
  61. max_keepalive_connections: The maximum number of idle HTTP connections
  62. that will be maintained in the pool.
  63. keepalive_expiry: The duration in seconds that an idle HTTP connection
  64. may be maintained for before being expired from the pool.
  65. http1: A boolean indicating if HTTP/1.1 requests should be supported
  66. by the connection pool. Defaults to True.
  67. http2: A boolean indicating if HTTP/2 requests should be supported by
  68. the connection pool. Defaults to False.
  69. retries: The maximum number of retries when trying to establish a
  70. connection.
  71. local_address: Local address to connect from. Can also be used to connect
  72. using a particular address family. Using `local_address="0.0.0.0"`
  73. will connect using an `AF_INET` address (IPv4), while using
  74. `local_address="::"` will connect using an `AF_INET6` address (IPv6).
  75. uds: Path to a Unix Domain Socket to use instead of TCP sockets.
  76. network_backend: A backend instance to use for handling network I/O.
  77. socket_options: Socket options that have to be included
  78. in the TCP socket when the connection was established.
  79. """
  80. self._ssl_context = ssl_context
  81. self._proxy = proxy
  82. self._max_connections = (
  83. sys.maxsize if max_connections is None else max_connections
  84. )
  85. self._max_keepalive_connections = (
  86. sys.maxsize
  87. if max_keepalive_connections is None
  88. else max_keepalive_connections
  89. )
  90. self._max_keepalive_connections = min(
  91. self._max_connections, self._max_keepalive_connections
  92. )
  93. self._keepalive_expiry = keepalive_expiry
  94. self._http1 = http1
  95. self._http2 = http2
  96. self._retries = retries
  97. self._local_address = local_address
  98. self._uds = uds
  99. self._network_backend = (
  100. AutoBackend() if network_backend is None else network_backend
  101. )
  102. self._socket_options = socket_options
  103. # The mutable state on a connection pool is the queue of incoming requests,
  104. # and the set of connections that are servicing those requests.
  105. self._connections: list[AsyncConnectionInterface] = []
  106. self._requests: list[AsyncPoolRequest] = []
  107. # We only mutate the state of the connection pool within an 'optional_thread_lock'
  108. # context. This holds a threading lock unless we're running in async mode,
  109. # in which case it is a no-op.
  110. self._optional_thread_lock = AsyncThreadLock()
  111. def create_connection(self, origin: Origin) -> AsyncConnectionInterface:
  112. if self._proxy is not None:
  113. if self._proxy.url.scheme in (b"socks5", b"socks5h"):
  114. from .socks_proxy import AsyncSocks5Connection
  115. return AsyncSocks5Connection(
  116. proxy_origin=self._proxy.url.origin,
  117. proxy_auth=self._proxy.auth,
  118. remote_origin=origin,
  119. ssl_context=self._ssl_context,
  120. keepalive_expiry=self._keepalive_expiry,
  121. http1=self._http1,
  122. http2=self._http2,
  123. network_backend=self._network_backend,
  124. )
  125. elif origin.scheme == b"http":
  126. from .http_proxy import AsyncForwardHTTPConnection
  127. return AsyncForwardHTTPConnection(
  128. proxy_origin=self._proxy.url.origin,
  129. proxy_headers=self._proxy.headers,
  130. proxy_ssl_context=self._proxy.ssl_context,
  131. remote_origin=origin,
  132. keepalive_expiry=self._keepalive_expiry,
  133. network_backend=self._network_backend,
  134. )
  135. from .http_proxy import AsyncTunnelHTTPConnection
  136. return AsyncTunnelHTTPConnection(
  137. proxy_origin=self._proxy.url.origin,
  138. proxy_headers=self._proxy.headers,
  139. proxy_ssl_context=self._proxy.ssl_context,
  140. remote_origin=origin,
  141. ssl_context=self._ssl_context,
  142. keepalive_expiry=self._keepalive_expiry,
  143. http1=self._http1,
  144. http2=self._http2,
  145. network_backend=self._network_backend,
  146. )
  147. return AsyncHTTPConnection(
  148. origin=origin,
  149. ssl_context=self._ssl_context,
  150. keepalive_expiry=self._keepalive_expiry,
  151. http1=self._http1,
  152. http2=self._http2,
  153. retries=self._retries,
  154. local_address=self._local_address,
  155. uds=self._uds,
  156. network_backend=self._network_backend,
  157. socket_options=self._socket_options,
  158. )
  159. @property
  160. def connections(self) -> list[AsyncConnectionInterface]:
  161. """
  162. Return a list of the connections currently in the pool.
  163. For example:
  164. ```python
  165. >>> pool.connections
  166. [
  167. <AsyncHTTPConnection ['https://example.com:443', HTTP/1.1, ACTIVE, Request Count: 6]>,
  168. <AsyncHTTPConnection ['https://example.com:443', HTTP/1.1, IDLE, Request Count: 9]> ,
  169. <AsyncHTTPConnection ['http://example.com:80', HTTP/1.1, IDLE, Request Count: 1]>,
  170. ]
  171. ```
  172. """
  173. return list(self._connections)
  174. async def handle_async_request(self, request: Request) -> Response:
  175. """
  176. Send an HTTP request, and return an HTTP response.
  177. This is the core implementation that is called into by `.request()` or `.stream()`.
  178. """
  179. scheme = request.url.scheme.decode()
  180. if scheme == "":
  181. raise UnsupportedProtocol(
  182. "Request URL is missing an 'http://' or 'https://' protocol."
  183. )
  184. if scheme not in ("http", "https", "ws", "wss"):
  185. raise UnsupportedProtocol(
  186. f"Request URL has an unsupported protocol '{scheme}://'."
  187. )
  188. timeouts = request.extensions.get("timeout", {})
  189. timeout = timeouts.get("pool", None)
  190. with self._optional_thread_lock:
  191. # Add the incoming request to our request queue.
  192. pool_request = AsyncPoolRequest(request)
  193. self._requests.append(pool_request)
  194. try:
  195. while True:
  196. with self._optional_thread_lock:
  197. # Assign incoming requests to available connections,
  198. # closing or creating new connections as required.
  199. closing = self._assign_requests_to_connections()
  200. await self._close_connections(closing)
  201. # Wait until this request has an assigned connection.
  202. connection = await pool_request.wait_for_connection(timeout=timeout)
  203. try:
  204. # Send the request on the assigned connection.
  205. response = await connection.handle_async_request(
  206. pool_request.request
  207. )
  208. except ConnectionNotAvailable:
  209. # In some cases a connection may initially be available to
  210. # handle a request, but then become unavailable.
  211. #
  212. # In this case we clear the connection and try again.
  213. pool_request.clear_connection()
  214. else:
  215. break # pragma: nocover
  216. except BaseException as exc:
  217. with self._optional_thread_lock:
  218. # For any exception or cancellation we remove the request from
  219. # the queue, and then re-assign requests to connections.
  220. self._requests.remove(pool_request)
  221. closing = self._assign_requests_to_connections()
  222. await self._close_connections(closing)
  223. raise exc from None
  224. # Return the response. Note that in this case we still have to manage
  225. # the point at which the response is closed.
  226. assert isinstance(response.stream, typing.AsyncIterable)
  227. return Response(
  228. status=response.status,
  229. headers=response.headers,
  230. content=PoolByteStream(
  231. stream=response.stream, pool_request=pool_request, pool=self
  232. ),
  233. extensions=response.extensions,
  234. )
  235. def _assign_requests_to_connections(self) -> list[AsyncConnectionInterface]:
  236. """
  237. Manage the state of the connection pool, assigning incoming
  238. requests to connections as available.
  239. Called whenever a new request is added or removed from the pool.
  240. Any closing connections are returned, allowing the I/O for closing
  241. those connections to be handled seperately.
  242. """
  243. closing_connections = []
  244. # First we handle cleaning up any connections that are closed,
  245. # have expired their keep-alive, or surplus idle connections.
  246. for connection in list(self._connections):
  247. if connection.is_closed():
  248. # log: "removing closed connection"
  249. self._connections.remove(connection)
  250. elif connection.has_expired():
  251. # log: "closing expired connection"
  252. self._connections.remove(connection)
  253. closing_connections.append(connection)
  254. elif (
  255. connection.is_idle()
  256. and len([connection.is_idle() for connection in self._connections])
  257. > self._max_keepalive_connections
  258. ):
  259. # log: "closing idle connection"
  260. self._connections.remove(connection)
  261. closing_connections.append(connection)
  262. # Assign queued requests to connections.
  263. queued_requests = [request for request in self._requests if request.is_queued()]
  264. for pool_request in queued_requests:
  265. origin = pool_request.request.url.origin
  266. available_connections = [
  267. connection
  268. for connection in self._connections
  269. if connection.can_handle_request(origin) and connection.is_available()
  270. ]
  271. idle_connections = [
  272. connection for connection in self._connections if connection.is_idle()
  273. ]
  274. # There are three cases for how we may be able to handle the request:
  275. #
  276. # 1. There is an existing connection that can handle the request.
  277. # 2. We can create a new connection to handle the request.
  278. # 3. We can close an idle connection and then create a new connection
  279. # to handle the request.
  280. if available_connections:
  281. # log: "reusing existing connection"
  282. connection = available_connections[0]
  283. pool_request.assign_to_connection(connection)
  284. elif len(self._connections) < self._max_connections:
  285. # log: "creating new connection"
  286. connection = self.create_connection(origin)
  287. self._connections.append(connection)
  288. pool_request.assign_to_connection(connection)
  289. elif idle_connections:
  290. # log: "closing idle connection"
  291. connection = idle_connections[0]
  292. self._connections.remove(connection)
  293. closing_connections.append(connection)
  294. # log: "creating new connection"
  295. connection = self.create_connection(origin)
  296. self._connections.append(connection)
  297. pool_request.assign_to_connection(connection)
  298. return closing_connections
  299. async def _close_connections(self, closing: list[AsyncConnectionInterface]) -> None:
  300. # Close connections which have been removed from the pool.
  301. with AsyncShieldCancellation():
  302. for connection in closing:
  303. await connection.aclose()
  304. async def aclose(self) -> None:
  305. # Explicitly close the connection pool.
  306. # Clears all existing requests and connections.
  307. with self._optional_thread_lock:
  308. closing_connections = list(self._connections)
  309. self._connections = []
  310. await self._close_connections(closing_connections)
  311. async def __aenter__(self) -> AsyncConnectionPool:
  312. return self
  313. async def __aexit__(
  314. self,
  315. exc_type: type[BaseException] | None = None,
  316. exc_value: BaseException | None = None,
  317. traceback: types.TracebackType | None = None,
  318. ) -> None:
  319. await self.aclose()
  320. def __repr__(self) -> str:
  321. class_name = self.__class__.__name__
  322. with self._optional_thread_lock:
  323. request_is_queued = [request.is_queued() for request in self._requests]
  324. connection_is_idle = [
  325. connection.is_idle() for connection in self._connections
  326. ]
  327. num_active_requests = request_is_queued.count(False)
  328. num_queued_requests = request_is_queued.count(True)
  329. num_active_connections = connection_is_idle.count(False)
  330. num_idle_connections = connection_is_idle.count(True)
  331. requests_info = (
  332. f"Requests: {num_active_requests} active, {num_queued_requests} queued"
  333. )
  334. connection_info = (
  335. f"Connections: {num_active_connections} active, {num_idle_connections} idle"
  336. )
  337. return f"<{class_name} [{requests_info} | {connection_info}]>"
  338. class PoolByteStream:
  339. def __init__(
  340. self,
  341. stream: typing.AsyncIterable[bytes],
  342. pool_request: AsyncPoolRequest,
  343. pool: AsyncConnectionPool,
  344. ) -> None:
  345. self._stream = stream
  346. self._pool_request = pool_request
  347. self._pool = pool
  348. self._closed = False
  349. async def __aiter__(self) -> typing.AsyncIterator[bytes]:
  350. try:
  351. async for part in self._stream:
  352. yield part
  353. except BaseException as exc:
  354. await self.aclose()
  355. raise exc from None
  356. async def aclose(self) -> None:
  357. if not self._closed:
  358. self._closed = True
  359. with AsyncShieldCancellation():
  360. if hasattr(self._stream, "aclose"):
  361. await self._stream.aclose()
  362. with self._pool._optional_thread_lock:
  363. self._pool._requests.remove(self._pool_request)
  364. closing = self._pool._assign_requests_to_connections()
  365. await self._pool._close_connections(closing)