_transport.py 17 KB

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