_asgi.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  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. receive_started = asyncio.Event()
  112. async def read_request_content() -> None:
  113. try:
  114. await receive_started.wait()
  115. if isinstance(request.content, bytes):
  116. await receive_queue.put(request.content)
  117. await receive_queue.put(None)
  118. return
  119. async for chunk in request.content:
  120. if not isinstance(chunk, bytes):
  121. msg = "Request not bytes object"
  122. raise WriteError(msg) # noqa: TRY301
  123. await receive_queue.put(chunk)
  124. await receive_queue.put(None)
  125. except Exception as e:
  126. await receive_queue.put(e)
  127. finally:
  128. try:
  129. aclose = request.content.aclose # ty: ignore[unresolved-attribute]
  130. except AttributeError:
  131. pass
  132. else:
  133. await aclose()
  134. # Need a separate task to read the request body to allow
  135. # cancelling when response closes.
  136. request_task = asyncio.create_task(read_request_content())
  137. async def receive() -> ASGIReceiveEvent:
  138. receive_started.set()
  139. chunk = await receive_queue.get()
  140. if chunk is None:
  141. return {"type": "http.request", "body": b"", "more_body": False}
  142. if isinstance(chunk, Exception):
  143. if self._http_version != HTTPVersion.HTTP2:
  144. msg = f"Request failed: {chunk}"
  145. else:
  146. # With HTTP/2, reqwest seems to squash the original error message.
  147. msg = "Request failed: stream error sent by user"
  148. raise WriteError(msg) from chunk
  149. if isinstance(chunk, BaseException):
  150. raise chunk
  151. return {"type": "http.request", "body": chunk, "more_body": True}
  152. send_queue: asyncio.Queue[ASGISendEvent | Exception] = asyncio.Queue()
  153. async def send(message: ASGISendEvent) -> None:
  154. await send_queue.put(message)
  155. async def run_app() -> None:
  156. try:
  157. await self._app(scope, receive, send)
  158. except asyncio.TimeoutError as e:
  159. send_queue.put_nowait(TimeoutError(str(e)))
  160. except Exception as e:
  161. self._app_exception = e
  162. send_queue.put_nowait(e)
  163. app_task = asyncio.create_task(run_app())
  164. message = await send_queue.get()
  165. if isinstance(message, Exception):
  166. request_task.cancel()
  167. with contextlib.suppress(BaseException):
  168. await app_task
  169. await request_task
  170. if isinstance(message, (ConnectionError, TimeoutError)):
  171. raise message
  172. return Response(
  173. status=500,
  174. http_version=self._http_version,
  175. headers=Headers((("content-type", "text/plain"),)),
  176. content=str(message).encode(),
  177. )
  178. assert message["type"] == "http.response.start" # noqa: S101
  179. status = message["status"]
  180. headers = Headers(
  181. (
  182. (k.decode("utf-8"), v.decode("utf-8"))
  183. for k, v in message.get("headers", [])
  184. )
  185. )
  186. trailers = (
  187. Headers()
  188. if self._http_version == HTTPVersion.HTTP2
  189. and request.headers.get("te") == "trailers"
  190. else None
  191. )
  192. decompressor = get_decompressor(headers.get("content-encoding"))
  193. response_content = ResponseContent(
  194. send_queue,
  195. request_task,
  196. trailers,
  197. app_task,
  198. decompressor,
  199. read_trailers=message.get("trailers", False),
  200. )
  201. return Response(
  202. status=status,
  203. http_version=self._http_version,
  204. headers=headers,
  205. content=response_content,
  206. trailers=trailers,
  207. )
  208. async def __aenter__(self) -> ASGITransport:
  209. await self.run_lifespan()
  210. return self
  211. async def __aexit__(
  212. self,
  213. _exc_type: type[BaseException] | None,
  214. _exc_value: BaseException | None,
  215. _traceback: TracebackType | None,
  216. ) -> None:
  217. await self.close()
  218. async def run_lifespan(self) -> None:
  219. scope: LifespanScope = {"type": "lifespan", "asgi": _asgi, "state": self._state}
  220. receive_queue: asyncio.Queue[LifespanStartupEvent | LifespanShutdownEvent] = (
  221. asyncio.Queue()
  222. )
  223. async def receive() -> LifespanStartupEvent | LifespanShutdownEvent:
  224. return await receive_queue.get()
  225. send_queue: asyncio.Queue[ASGISendEvent | Exception] = asyncio.Queue()
  226. async def send(message: ASGISendEvent) -> None:
  227. await send_queue.put(message)
  228. async def run_app() -> None:
  229. try:
  230. await self._app(scope, receive, send)
  231. except Exception as e:
  232. send_queue.put_nowait(e)
  233. task = asyncio.create_task(run_app())
  234. receive_queue.put_nowait({"type": "lifespan.startup"})
  235. message = await send_queue.get()
  236. if isinstance(message, Exception):
  237. # Lifespan not supported
  238. await task
  239. return
  240. self._lifespan = Lifespan(
  241. task=task, receive_queue=receive_queue, send_queue=send_queue
  242. )
  243. match message["type"]:
  244. case "lifespan.startup.complete":
  245. return
  246. case "lifespan.startup.failed":
  247. msg = (
  248. f"ASGI application failed to start up: {message.get('message', '')}"
  249. )
  250. raise RuntimeError(msg)
  251. async def close(self) -> None:
  252. if self._lifespan is None:
  253. return
  254. await self._lifespan.receive_queue.put({"type": "lifespan.shutdown"})
  255. message = await self._lifespan.send_queue.get()
  256. await self._lifespan.task
  257. if isinstance(message, Exception):
  258. raise message
  259. match message["type"]:
  260. case "lifespan.shutdown.complete":
  261. return
  262. case "lifespan.shutdown.failed":
  263. msg = f"ASGI application failed to shut down cleanly: {message.get('message', '')}"
  264. raise RuntimeError(msg)
  265. @property
  266. def app_exception(self) -> Exception | None:
  267. """The exception raised by the ASGI application, if any.
  268. This will be overwritten for any request which raises an exception, so it is generally
  269. expected to be used with a transport that is used only once, or in a precise order.
  270. """
  271. return self._app_exception
  272. class CancelResponse(Exception):
  273. pass
  274. class ResponseContent(AsyncIterator[bytes]):
  275. def __init__(
  276. self,
  277. send_queue: asyncio.Queue[ASGISendEvent | Exception],
  278. request_task: asyncio.Task[None],
  279. trailers: Headers | None,
  280. task: asyncio.Task[None],
  281. decompressor: Decompressor,
  282. *,
  283. read_trailers: bool,
  284. ) -> None:
  285. self._send_queue = send_queue
  286. self._request_task = request_task
  287. self._trailers = trailers
  288. self._task = task
  289. self._decompressor = decompressor
  290. self._read_trailers = read_trailers
  291. self._read_pending = False
  292. self._closed = False
  293. def __aiter__(self) -> AsyncIterator[bytes]:
  294. return self
  295. async def __anext__(self) -> bytes:
  296. if self._closed:
  297. raise StopAsyncIteration
  298. err: Exception | None = None
  299. body: bytes | None = None
  300. while True:
  301. self._read_pending = True
  302. try:
  303. message = await self._send_queue.get()
  304. finally:
  305. self._read_pending = False
  306. if isinstance(message, Exception):
  307. match message:
  308. case CancelResponse():
  309. err = StopAsyncIteration()
  310. break
  311. case WriteError() | TimeoutError():
  312. err = message
  313. break
  314. case ReadError():
  315. raise message
  316. case Exception():
  317. msg = "Error reading response body"
  318. raise ReadError(msg) from message
  319. match message["type"]:
  320. case "http.response.body":
  321. more_body = message.get("more_body", False)
  322. if not more_body and not self._read_trailers:
  323. await self._cleanup()
  324. if (body := message.get("body", b"")) or self._closed:
  325. return self._decompressor.feed(body, end=not more_body)
  326. case "http.response.trailers":
  327. if self._trailers is not None:
  328. for k, v in message.get("headers", []):
  329. self._trailers.add(k.decode("utf-8"), v.decode("utf-8"))
  330. if not message.get("more_trailers", False):
  331. break
  332. await self._cleanup()
  333. if err:
  334. raise err
  335. raise StopAsyncIteration
  336. async def aclose(self) -> None:
  337. if self._closed:
  338. return
  339. self._closed = True
  340. self._send_queue.put_nowait(ReadError("Response body read cancelled"))
  341. await self._cleanup()
  342. async def _cleanup(self) -> None:
  343. self._closed = True
  344. self._request_task.cancel()
  345. with contextlib.suppress(BaseException):
  346. await self._request_task
  347. self._task.cancel()
  348. with contextlib.suppress(BaseException):
  349. await self._task