_transport.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. from __future__ import annotations
  2. import asyncio
  3. import contextlib
  4. from typing import TYPE_CHECKING, cast
  5. import httpx
  6. from h2.errors import ErrorCodes
  7. from h2.events import StreamReset
  8. from pyqwest import (
  9. Headers,
  10. ReadError,
  11. RemoteProtocolError,
  12. Request,
  13. Response,
  14. StreamError,
  15. StreamErrorCode,
  16. SyncRequest,
  17. SyncResponse,
  18. SyncTransport,
  19. TooManyRedirects,
  20. Transport,
  21. WriteError,
  22. )
  23. from pyqwest._pyqwest import set_sync_timeout
  24. if TYPE_CHECKING:
  25. from collections.abc import AsyncIterator, Iterator
  26. class AsyncPyqwestTransport(httpx.AsyncBaseTransport):
  27. """An HTTPX transport implementation that delegates to pyqwest.
  28. This can be used with any existing code using httpx.AsyncClient, and will enable
  29. use of bidirectional streaming and response trailers.
  30. By default, [pyqwest.HTTPTransport][] follows redirects internally. To have
  31. HTTPX handle it instead, for example to set `response.history`, configure
  32. the pyqwest transport with `follow_redirects=False`.
  33. """
  34. _transport: Transport
  35. def __init__(self, transport: Transport) -> None:
  36. """Creates a new AsyncPyQwestTransport.
  37. Args:
  38. transport: The pyqwest transport to delegate requests to.
  39. """
  40. self._transport = transport
  41. async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
  42. check_scheme(request)
  43. timeout = convert_timeout(request.extensions)
  44. deadline = None
  45. if timeout is not None:
  46. deadline = asyncio.get_running_loop().time() + timeout
  47. try:
  48. pyqwest_request = Request(
  49. request.method,
  50. str(request.url),
  51. headers=convert_headers(request),
  52. content=async_request_content(request.stream),
  53. )
  54. except ValueError as e:
  55. raise map_value_error(e, request) from e
  56. try:
  57. response = await asyncio.wait_for(
  58. self._transport.execute(pyqwest_request), remaining_time(deadline)
  59. )
  60. except StreamError as e:
  61. # Must precede RemoteProtocolError, which it subclasses, to keep the
  62. # richer stream error message.
  63. raise map_stream_error(e) from e
  64. except RemoteProtocolError as e:
  65. raise map_remote_protocol_error(e, request) from e
  66. except TooManyRedirects as e:
  67. raise httpx.TooManyRedirects(str(e), request=request) from e
  68. except ConnectionError as e:
  69. raise map_connection_error(e, request) from e
  70. except (TimeoutError, asyncio.TimeoutError) as e:
  71. raise map_timeout_error(e, request) from e
  72. except (ReadError, WriteError) as e:
  73. raise map_network_error(e, request) from e
  74. def get_trailers() -> httpx.Headers:
  75. return httpx.Headers(tuple(response.trailers.items()))
  76. return httpx.Response(
  77. status_code=response.status,
  78. headers=httpx.Headers(tuple(response.headers.items())),
  79. stream=AsyncIteratorByteStream(response, deadline),
  80. extensions={"get_trailers": get_trailers},
  81. )
  82. def async_request_content(
  83. stream: httpx.AsyncByteStream | httpx.SyncByteStream | httpx.ByteStream,
  84. ) -> bytes | AsyncIterator[bytes]:
  85. match stream:
  86. case httpx.ByteStream():
  87. # Buffered bytes
  88. return next(iter(stream))
  89. case _:
  90. return async_request_content_iter(stream)
  91. async def async_request_content_iter(
  92. stream: httpx.AsyncByteStream | httpx.SyncByteStream,
  93. ) -> AsyncIterator[bytes]:
  94. match stream:
  95. case httpx.AsyncByteStream():
  96. async with contextlib.aclosing(stream):
  97. async for chunk in stream:
  98. yield chunk
  99. case httpx.SyncByteStream():
  100. with contextlib.closing(stream):
  101. stream_iter = iter(stream)
  102. while True:
  103. chunk = await asyncio.to_thread(next, stream_iter, None)
  104. if chunk is None:
  105. break
  106. yield chunk # ty: ignore[invalid-yield] # seems to be narrowing bug
  107. class AsyncIteratorByteStream(httpx.AsyncByteStream):
  108. def __init__(self, response: Response, deadline: float | None = None) -> None:
  109. self._response = response
  110. self._deadline = deadline
  111. self._is_stream_consumed = False
  112. async def __aiter__(self) -> AsyncIterator[bytes]:
  113. if self._is_stream_consumed:
  114. raise httpx.StreamConsumed
  115. self._is_stream_consumed = True
  116. try:
  117. if self._deadline is None:
  118. async for chunk in self._response.content:
  119. yield bytes(chunk)
  120. else:
  121. # Content is read after handle_async_request returns, so the
  122. # request timeout can only be applied here, not there.
  123. content = self._response.content
  124. while True:
  125. try:
  126. chunk = await asyncio.wait_for(
  127. anext(content), remaining_time(self._deadline)
  128. )
  129. except StopAsyncIteration:
  130. break
  131. yield bytes(chunk)
  132. except StreamError as e:
  133. # Must precede RemoteProtocolError, which it subclasses, to keep the
  134. # richer stream error message.
  135. raise map_stream_error(e) from e
  136. except RemoteProtocolError as e:
  137. raise map_remote_protocol_error(e) from e
  138. except ConnectionError as e:
  139. raise map_connection_error(e) from e
  140. except (TimeoutError, asyncio.TimeoutError) as e:
  141. raise map_timeout_error(e) from e
  142. except (ReadError, WriteError) as e:
  143. raise map_network_error(e) from e
  144. async def aclose(self) -> None:
  145. await self._response.aclose()
  146. class PyqwestTransport(httpx.BaseTransport):
  147. """An HTTPX transport implementation that delegates to pyqwest.
  148. This can be used with any existing code using httpx.Client, and will enable
  149. use of bidirectional streaming and response trailers.
  150. By default, [pyqwest.SyncHTTPTransport][] follows redirects internally. To have
  151. HTTPX handle it instead, for example to set `response.history`, configure
  152. the pyqwest transport with `follow_redirects=False`.
  153. """
  154. _transport: SyncTransport
  155. def __init__(self, transport: SyncTransport) -> None:
  156. """Creates a new PyQwestTransport.
  157. Args:
  158. transport: The pyqwest transport to delegate requests to.
  159. """
  160. self._transport = transport
  161. def handle_request(self, request: httpx.Request) -> httpx.Response:
  162. check_scheme(request)
  163. timeout = convert_timeout(request.extensions)
  164. try:
  165. pyqwest_request = SyncRequest(
  166. request.method,
  167. str(request.url),
  168. headers=convert_headers(request),
  169. content=sync_request_content(request.stream),
  170. )
  171. except ValueError as e:
  172. raise map_value_error(e, request) from e
  173. timeout_manager = None
  174. if timeout is not None:
  175. timeout_manager = set_sync_timeout(timeout)
  176. timeout_manager.__enter__()
  177. try:
  178. response = self._transport.execute_sync(pyqwest_request)
  179. except StreamError as e:
  180. # Must precede RemoteProtocolError, which it subclasses, to keep the
  181. # richer stream error message.
  182. raise map_stream_error(e) from e
  183. except RemoteProtocolError as e:
  184. raise map_remote_protocol_error(e, request) from e
  185. except TooManyRedirects as e:
  186. raise httpx.TooManyRedirects(str(e), request=request) from e
  187. except ConnectionError as e:
  188. raise map_connection_error(e, request) from e
  189. except TimeoutError as e:
  190. raise map_timeout_error(e, request) from e
  191. except (ReadError, WriteError) as e:
  192. raise map_network_error(e, request) from e
  193. finally:
  194. if timeout_manager is not None:
  195. timeout_manager.__exit__(None, None, None)
  196. def get_trailers() -> httpx.Headers:
  197. return httpx.Headers(tuple(response.trailers.items()))
  198. return httpx.Response(
  199. status_code=response.status,
  200. headers=httpx.Headers(tuple(response.headers.items())),
  201. stream=IteratorByteStream(response),
  202. extensions={"get_trailers": get_trailers},
  203. )
  204. def sync_request_content(
  205. stream: httpx.AsyncByteStream | httpx.SyncByteStream | httpx.ByteStream,
  206. ) -> bytes | Iterator[bytes]:
  207. match stream:
  208. case httpx.ByteStream():
  209. # Buffered bytes
  210. return next(iter(stream))
  211. case _:
  212. return sync_request_content_iter(stream)
  213. def sync_request_content_iter(
  214. stream: httpx.AsyncByteStream | httpx.SyncByteStream,
  215. ) -> Iterator[bytes]:
  216. # Some streams, notably MultipartStream, subclass both SyncByteStream and
  217. # AsyncByteStream, so the sync case must be matched first.
  218. match stream:
  219. case httpx.SyncByteStream():
  220. with contextlib.closing(stream):
  221. yield from stream
  222. case httpx.AsyncByteStream():
  223. msg = "unreachable"
  224. raise TypeError(msg)
  225. class IteratorByteStream(httpx.SyncByteStream):
  226. def __init__(self, response: SyncResponse) -> None:
  227. self._response = response
  228. self._is_stream_consumed = False
  229. def __iter__(self) -> Iterator[bytes]:
  230. if self._is_stream_consumed:
  231. raise httpx.StreamConsumed
  232. self._is_stream_consumed = True
  233. try:
  234. for chunk in self._response.content:
  235. yield bytes(chunk)
  236. except StreamError as e:
  237. # Must precede RemoteProtocolError, which it subclasses, to keep the
  238. # richer stream error message.
  239. raise map_stream_error(e) from e
  240. except RemoteProtocolError as e:
  241. raise map_remote_protocol_error(e) from e
  242. except ConnectionError as e:
  243. raise map_connection_error(e) from e
  244. except TimeoutError as e:
  245. raise map_timeout_error(e) from e
  246. except (ReadError, WriteError) as e:
  247. raise map_network_error(e) from e
  248. def close(self) -> None:
  249. self._response.close()
  250. # Headers that are managed by the transport and should not be forwarded.
  251. TRANSPORT_HEADERS = {
  252. "connection",
  253. "keep-alive",
  254. "proxy-connection",
  255. "transfer-encoding",
  256. "upgrade",
  257. }
  258. def convert_headers(request: httpx.Request) -> Headers:
  259. # httpx adds a host header matching the URL to every request, but the
  260. # transport derives it from the URL itself (:authority on HTTP/2, where a
  261. # redundant literal host field is rejected by some servers). Only forward
  262. # host when the user overrode it to a different value. There isn't any
  263. # way to detect if the user explicitly set the host header at this layer
  264. # so the best we can do is compare it to the URL.
  265. # HTTP defines host as case-insensitive
  266. url_host = request.url.netloc.decode("ascii").lower()
  267. headers = Headers()
  268. for name, value in request.headers.multi_items():
  269. lower_name = name.lower()
  270. if lower_name in TRANSPORT_HEADERS:
  271. continue
  272. if lower_name == "host" and value.lower() == url_host:
  273. continue
  274. headers.add(name, value)
  275. return headers
  276. def check_scheme(request: httpx.Request) -> None:
  277. # The transport only speaks HTTP, and the underlying client reports anything
  278. # else as a generic client build failure, so reject it here with the same
  279. # error httpx uses.
  280. scheme = request.url.scheme
  281. if scheme not in ("http", "https"):
  282. msg = (
  283. f"Request URL has an unsupported protocol '{scheme}://'."
  284. if scheme
  285. else "Request URL is missing an 'http://' or 'https://' protocol."
  286. )
  287. raise httpx.UnsupportedProtocol(msg, request=request)
  288. def convert_timeout(extensions: dict) -> float | None:
  289. httpx_timeout = cast("dict | None", extensions.get("timeout"))
  290. if httpx_timeout is None:
  291. return None
  292. # reqwest does not support setting individual timeout settings
  293. # per call, only an operation timeout, so we need to approximate
  294. # that from the httpx timeout dict. Connect usually happens once
  295. # and can be given a longer timeout - we assume the operation timeout
  296. # is the max of read/write if present, or connect if not. We ignore
  297. # pool for now
  298. read_timeout = httpx_timeout.get("read", -1)
  299. if read_timeout is None:
  300. read_timeout = -1
  301. write_timeout = httpx_timeout.get("write", -1)
  302. if write_timeout is None:
  303. write_timeout = -1
  304. operation_timeout = max(read_timeout, write_timeout)
  305. if operation_timeout != -1:
  306. return operation_timeout
  307. return httpx_timeout.get("connect")
  308. def remaining_time(deadline: float | None) -> float | None:
  309. if deadline is None:
  310. return None
  311. return max(deadline - asyncio.get_running_loop().time(), 0.0)
  312. def map_connection_error(
  313. e: ConnectionError, request: httpx.Request | None = None
  314. ) -> httpx.ConnectError | httpx.ConnectTimeout:
  315. if isinstance(e, TimeoutError):
  316. return httpx.ConnectTimeout(str(e) or "timed out", request=request)
  317. return httpx.ConnectError(str(e), request=request)
  318. def map_timeout_error(
  319. e: BaseException, request: httpx.Request | None = None
  320. ) -> httpx.ReadTimeout:
  321. # Connect timeouts are raised as ConnectTimeout (a ConnectionError) and
  322. # mapped by map_connection_error. The remaining operation timeout covers
  323. # read/write without distinguishing which phase expired, so it maps to
  324. # ReadTimeout to satisfy the httpx.TimeoutException contract.
  325. return httpx.ReadTimeout(str(e) or "timed out", request=request)
  326. def map_value_error(
  327. e: ValueError, request: httpx.Request | None = None
  328. ) -> httpx.LocalProtocolError:
  329. # The method, URL or headers were rejected while building the request, so
  330. # nothing was sent. httpx reports malformed requests as LocalProtocolError.
  331. return httpx.LocalProtocolError(str(e), request=request)
  332. def map_network_error(
  333. e: ReadError | WriteError, request: httpx.Request | None = None
  334. ) -> httpx.ReadError | httpx.WriteError:
  335. if isinstance(e, WriteError):
  336. return httpx.WriteError(str(e), request=request)
  337. return httpx.ReadError(str(e), request=request)
  338. def map_remote_protocol_error(
  339. e: RemoteProtocolError, request: httpx.Request | None = None
  340. ) -> httpx.RemoteProtocolError:
  341. # The peer sent something that isn't valid HTTP, or cut a message short.
  342. return httpx.RemoteProtocolError(str(e), request=request)
  343. def map_stream_error(e: StreamError) -> httpx.RemoteProtocolError:
  344. match e.code:
  345. case StreamErrorCode.NO_ERROR:
  346. code = ErrorCodes.NO_ERROR
  347. case StreamErrorCode.PROTOCOL_ERROR:
  348. code = ErrorCodes.PROTOCOL_ERROR
  349. case StreamErrorCode.INTERNAL_ERROR:
  350. code = ErrorCodes.INTERNAL_ERROR
  351. case StreamErrorCode.FLOW_CONTROL_ERROR:
  352. code = ErrorCodes.FLOW_CONTROL_ERROR
  353. case StreamErrorCode.SETTINGS_TIMEOUT:
  354. code = ErrorCodes.SETTINGS_TIMEOUT
  355. case StreamErrorCode.STREAM_CLOSED:
  356. code = ErrorCodes.STREAM_CLOSED
  357. case StreamErrorCode.FRAME_SIZE_ERROR:
  358. code = ErrorCodes.FRAME_SIZE_ERROR
  359. case StreamErrorCode.REFUSED_STREAM:
  360. code = ErrorCodes.REFUSED_STREAM
  361. case StreamErrorCode.CANCEL:
  362. code = ErrorCodes.CANCEL
  363. case StreamErrorCode.COMPRESSION_ERROR:
  364. code = ErrorCodes.COMPRESSION_ERROR
  365. case StreamErrorCode.CONNECT_ERROR:
  366. code = ErrorCodes.CONNECT_ERROR
  367. case StreamErrorCode.ENHANCE_YOUR_CALM:
  368. code = ErrorCodes.ENHANCE_YOUR_CALM
  369. case StreamErrorCode.INADEQUATE_SECURITY:
  370. code = ErrorCodes.INADEQUATE_SECURITY
  371. case StreamErrorCode.HTTP_1_1_REQUIRED:
  372. code = ErrorCodes.HTTP_1_1_REQUIRED
  373. case _:
  374. code = ErrorCodes.INTERNAL_ERROR
  375. return httpx.RemoteProtocolError(str(StreamReset(stream_id=-1, error_code=code)))