_asgi.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. from __future__ import annotations
  2. import asyncio
  3. import contextlib
  4. from collections.abc import AsyncIterator
  5. from dataclasses import dataclass
  6. from typing import TYPE_CHECKING, Any
  7. from urllib.parse import unquote, urlparse
  8. from pyqwest import (
  9. Headers,
  10. HTTPVersion,
  11. ReadError,
  12. Request,
  13. Response,
  14. Transport,
  15. WriteError,
  16. )
  17. from ._asgi_compatibility import guarantee_single_callable
  18. from ._decompress import Decompressor, get_decompressor
  19. if TYPE_CHECKING:
  20. from types import TracebackType
  21. from asgiref.typing import (
  22. ASGI3Application,
  23. ASGIApplication,
  24. ASGIReceiveEvent,
  25. ASGISendEvent,
  26. ASGIVersions,
  27. HTTPScope,
  28. LifespanScope,
  29. LifespanShutdownEvent,
  30. LifespanStartupEvent,
  31. )
  32. _asgi: ASGIVersions = {"version": "3.0", "spec_version": "2.5"}
  33. _extensions = {"http.response.trailers": {}}
  34. @dataclass(frozen=True)
  35. class Lifespan:
  36. task: asyncio.Task[None]
  37. receive_queue: asyncio.Queue[LifespanStartupEvent | LifespanShutdownEvent]
  38. send_queue: asyncio.Queue[ASGISendEvent | Exception]
  39. class ASGITransport(Transport):
  40. """Transport implementation that directly invokes an ASGI application. Useful for testing.
  41. The ASGI transport supports lifespan - to use it, make sure to use the transport as an
  42. asynchronous context manager. Lifespan startup will be run on entering and shutdown when
  43. exiting.
  44. """
  45. _app: ASGI3Application
  46. _http_version: HTTPVersion
  47. _client: tuple[str, int]
  48. _state: dict[str, Any]
  49. _lifespan: Lifespan | None
  50. _app_exception: Exception | None
  51. def __init__(
  52. self,
  53. app: ASGIApplication,
  54. http_version: HTTPVersion = HTTPVersion.HTTP2,
  55. client: tuple[str, int] = ("127.0.0.1", 111),
  56. ) -> None:
  57. """Creates a new ASGI transport.
  58. Args:
  59. app: The ASGI application to invoke.
  60. http_version: The HTTP version to mimic for requests. Note, semantics such as lack of
  61. bidirectional streaming for HTTP/1 are not enforced.
  62. client: The (host, port) tuple to use for the client address in the ASGI scope.
  63. """
  64. self._app = guarantee_single_callable(app)
  65. self._http_version = http_version
  66. self._client = client
  67. self._state = {}
  68. self._lifespan = None
  69. self._app_exception = None
  70. async def execute(self, request: Request) -> Response:
  71. parsed_url = urlparse(request.url)
  72. raw_path = parsed_url.path or "/"
  73. path = unquote(raw_path)
  74. match self._http_version:
  75. case HTTPVersion.HTTP1:
  76. http_version = "1.1"
  77. case HTTPVersion.HTTP2:
  78. http_version = "2"
  79. case HTTPVersion.HTTP3:
  80. http_version = "3"
  81. case _:
  82. http_version = "1.1"
  83. request_headers = Headers(request.headers.items())
  84. if "host" not in request_headers:
  85. request_headers["host"] = parsed_url.netloc
  86. if request._json and "content-type" not in request_headers: # noqa: SLF001
  87. request_headers["content-type"] = "application/json"
  88. scope: HTTPScope = {
  89. "type": "http",
  90. "asgi": _asgi,
  91. "http_version": http_version,
  92. "method": request.method,
  93. "scheme": parsed_url.scheme,
  94. "path": path,
  95. "raw_path": raw_path.encode(),
  96. "query_string": parsed_url.query.encode(),
  97. "headers": [
  98. (k.lower().encode("utf-8"), v.encode("utf-8"))
  99. for k, v in request_headers.items()
  100. ],
  101. "server": (
  102. parsed_url.hostname or "",
  103. parsed_url.port or (443 if parsed_url.scheme == "https" else 80),
  104. ),
  105. "client": self._client,
  106. "extensions": _extensions,
  107. "state": self._state,
  108. "root_path": "",
  109. }
  110. receive_queue: asyncio.Queue[bytes | Exception | None] = asyncio.Queue(1)
  111. async def read_request_content() -> None:
  112. try:
  113. if isinstance(request.content, bytes):
  114. await receive_queue.put(request.content)
  115. await receive_queue.put(None)
  116. return
  117. async for chunk in request.content:
  118. if not isinstance(chunk, bytes):
  119. msg = "Request not bytes object"
  120. raise WriteError(msg) # noqa: TRY301
  121. await receive_queue.put(chunk)
  122. await receive_queue.put(None)
  123. except Exception as e:
  124. await receive_queue.put(e)
  125. finally:
  126. try:
  127. aclose = request.content.aclose # ty: ignore[unresolved-attribute]
  128. except AttributeError:
  129. pass
  130. else:
  131. await aclose()
  132. # Need a separate task to read the request body to allow
  133. # cancelling when response closes.
  134. request_task = asyncio.create_task(read_request_content())
  135. async def receive() -> ASGIReceiveEvent:
  136. chunk = await receive_queue.get()
  137. if chunk is None:
  138. return {"type": "http.request", "body": b"", "more_body": False}
  139. if isinstance(chunk, Exception):
  140. if self._http_version != HTTPVersion.HTTP2:
  141. msg = f"Request failed: {chunk}"
  142. else:
  143. # With HTTP/2, reqwest seems to squash the original error message.
  144. msg = "Request failed: stream error sent by user"
  145. raise WriteError(msg) from chunk
  146. if isinstance(chunk, BaseException):
  147. raise chunk
  148. return {"type": "http.request", "body": chunk, "more_body": True}
  149. send_queue: asyncio.Queue[ASGISendEvent | Exception] = asyncio.Queue()
  150. async def send(message: ASGISendEvent) -> None:
  151. await send_queue.put(message)
  152. async def run_app() -> None:
  153. try:
  154. await self._app(scope, receive, send)
  155. except asyncio.TimeoutError as e:
  156. send_queue.put_nowait(TimeoutError(str(e)))
  157. except Exception as e:
  158. self._app_exception = e
  159. send_queue.put_nowait(e)
  160. app_task = asyncio.create_task(run_app())
  161. message = await send_queue.get()
  162. if isinstance(message, Exception):
  163. request_task.cancel()
  164. with contextlib.suppress(BaseException):
  165. await app_task
  166. await request_task
  167. if isinstance(message, (ConnectionError, TimeoutError)):
  168. raise message
  169. return Response(
  170. status=500,
  171. http_version=self._http_version,
  172. headers=Headers((("content-type", "text/plain"),)),
  173. content=str(message).encode(),
  174. )
  175. assert message["type"] == "http.response.start" # noqa: S101
  176. status = message["status"]
  177. headers = Headers(
  178. (
  179. (k.decode("utf-8"), v.decode("utf-8"))
  180. for k, v in message.get("headers", [])
  181. )
  182. )
  183. trailers = (
  184. Headers()
  185. if self._http_version == HTTPVersion.HTTP2
  186. and request.headers.get("te") == "trailers"
  187. else None
  188. )
  189. decompressor = get_decompressor(headers.get("content-encoding"))
  190. response_content = ResponseContent(
  191. send_queue,
  192. request_task,
  193. trailers,
  194. app_task,
  195. decompressor,
  196. read_trailers=message.get("trailers", False),
  197. )
  198. return Response(
  199. status=status,
  200. http_version=self._http_version,
  201. headers=headers,
  202. content=response_content,
  203. trailers=trailers,
  204. )
  205. async def __aenter__(self) -> ASGITransport:
  206. await self.run_lifespan()
  207. return self
  208. async def __aexit__(
  209. self,
  210. _exc_type: type[BaseException] | None,
  211. _exc_value: BaseException | None,
  212. _traceback: TracebackType | None,
  213. ) -> None:
  214. await self.close()
  215. async def run_lifespan(self) -> None:
  216. scope: LifespanScope = {"type": "lifespan", "asgi": _asgi, "state": self._state}
  217. receive_queue: asyncio.Queue[LifespanStartupEvent | LifespanShutdownEvent] = (
  218. asyncio.Queue()
  219. )
  220. async def receive() -> LifespanStartupEvent | LifespanShutdownEvent:
  221. return await receive_queue.get()
  222. send_queue: asyncio.Queue[ASGISendEvent | Exception] = asyncio.Queue()
  223. async def send(message: ASGISendEvent) -> None:
  224. await send_queue.put(message)
  225. async def run_app() -> None:
  226. try:
  227. await self._app(scope, receive, send)
  228. except Exception as e:
  229. send_queue.put_nowait(e)
  230. task = asyncio.create_task(run_app())
  231. receive_queue.put_nowait({"type": "lifespan.startup"})
  232. message = await send_queue.get()
  233. if isinstance(message, Exception):
  234. # Lifespan not supported
  235. await task
  236. return
  237. self._lifespan = Lifespan(
  238. task=task, receive_queue=receive_queue, send_queue=send_queue
  239. )
  240. match message["type"]:
  241. case "lifespan.startup.complete":
  242. return
  243. case "lifespan.startup.failed":
  244. msg = (
  245. f"ASGI application failed to start up: {message.get('message', '')}"
  246. )
  247. raise RuntimeError(msg)
  248. async def close(self) -> None:
  249. if self._lifespan is None:
  250. return
  251. await self._lifespan.receive_queue.put({"type": "lifespan.shutdown"})
  252. message = await self._lifespan.send_queue.get()
  253. await self._lifespan.task
  254. if isinstance(message, Exception):
  255. raise message
  256. match message["type"]:
  257. case "lifespan.shutdown.complete":
  258. return
  259. case "lifespan.shutdown.failed":
  260. msg = f"ASGI application failed to shut down cleanly: {message.get('message', '')}"
  261. raise RuntimeError(msg)
  262. @property
  263. def app_exception(self) -> Exception | None:
  264. """The exception raised by the ASGI application, if any.
  265. This will be overwritten for any request which raises an exception, so it is generally
  266. expected to be used with a transport that is used only once, or in a precise order.
  267. """
  268. return self._app_exception
  269. class CancelResponse(Exception):
  270. pass
  271. class ResponseContent(AsyncIterator[bytes]):
  272. def __init__(
  273. self,
  274. send_queue: asyncio.Queue[ASGISendEvent | Exception],
  275. request_task: asyncio.Task[None],
  276. trailers: Headers | None,
  277. task: asyncio.Task[None],
  278. decompressor: Decompressor,
  279. *,
  280. read_trailers: bool,
  281. ) -> None:
  282. self._send_queue = send_queue
  283. self._request_task = request_task
  284. self._trailers = trailers
  285. self._task = task
  286. self._decompressor = decompressor
  287. self._read_trailers = read_trailers
  288. self._read_pending = False
  289. self._closed = False
  290. def __aiter__(self) -> AsyncIterator[bytes]:
  291. return self
  292. async def __anext__(self) -> bytes:
  293. if self._closed:
  294. raise StopAsyncIteration
  295. err: Exception | None = None
  296. body: bytes | None = None
  297. while True:
  298. self._read_pending = True
  299. try:
  300. message = await self._send_queue.get()
  301. finally:
  302. self._read_pending = False
  303. if isinstance(message, Exception):
  304. match message:
  305. case CancelResponse():
  306. err = StopAsyncIteration()
  307. break
  308. case WriteError() | TimeoutError():
  309. err = message
  310. break
  311. case ReadError():
  312. raise message
  313. case Exception():
  314. msg = "Error reading response body"
  315. raise ReadError(msg) from message
  316. match message["type"]:
  317. case "http.response.body":
  318. more_body = message.get("more_body", False)
  319. if not more_body and not self._read_trailers:
  320. await self._cleanup()
  321. if (body := message.get("body", b"")) or self._closed:
  322. return self._decompressor.feed(body, end=not more_body)
  323. case "http.response.trailers":
  324. if self._trailers is not None:
  325. for k, v in message.get("headers", []):
  326. self._trailers.add(k.decode("utf-8"), v.decode("utf-8"))
  327. if not message.get("more_trailers", False):
  328. break
  329. await self._cleanup()
  330. if err:
  331. raise err
  332. raise StopAsyncIteration
  333. async def aclose(self) -> None:
  334. if self._closed:
  335. return
  336. self._closed = True
  337. self._send_queue.put_nowait(ReadError("Response body read cancelled"))
  338. await self._cleanup()
  339. async def _cleanup(self) -> None:
  340. self._closed = True
  341. self._request_task.cancel()
  342. with contextlib.suppress(BaseException):
  343. await self._request_task
  344. self._task.cancel()
  345. with contextlib.suppress(BaseException):
  346. await self._task