_async.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. from __future__ import annotations
  2. import asyncio
  3. from http import HTTPStatus
  4. from typing import TYPE_CHECKING, final
  5. from pyqwest import HTTPHeaderName, ReadError, Transport
  6. from pyqwest._pyqwest import Request, Response, _Backoff
  7. from ._shared import (
  8. RetryMode,
  9. default_should_retry_request,
  10. default_should_retry_response,
  11. normalize_retry_mode,
  12. parse_retry_after,
  13. )
  14. if TYPE_CHECKING:
  15. from collections.abc import AsyncIterator, Callable
  16. class RetryTransport(Transport):
  17. """Retry middleware for async clients.
  18. Wrap a Transport with this class to allow requests to be automatically retried.
  19. By default, connection errors are retried for any request, while I/O errors and
  20. transient 429/5xx responses are retried only for GET, HEAD, PUT, and DELETE.
  21. The default behavior can be overridden by subclassing this class and overriding the
  22. `should_retry_request` and `should_retry_response` methods to suit any need.
  23. Examples:
  24. ```python
  25. from pyqwest import Client, HTTPTransport, Request
  26. from pyqwest.middleware.retry import RetryMode, RetryTransport
  27. class MyRetryTransport(RetryTransport):
  28. def should_retry_request(self, request: Request) -> bool | RetryMode:
  29. if request.url.endswith("/unsafe-method"):
  30. return False
  31. return RetryMode.UNBUFFERED
  32. client = Client(transport=MyRetryTransport(HTTPTransport()))
  33. await client.get(
  34. "http://localhost/safe-method"
  35. ) # will retry on transient errors
  36. await client.get("http://localhost/unsafe-method") # will not retry
  37. ```
  38. """
  39. _transport: Transport
  40. _initial_interval: float
  41. _randomization_factor: float
  42. _multiplier: float
  43. _max_interval: float
  44. _max_retries: int
  45. def __init__(
  46. self,
  47. transport: Transport,
  48. initial_interval: float = 0.5,
  49. randomization_factor: float = 0.5,
  50. multiplier: float = 1.5,
  51. max_interval: float = 60.0,
  52. max_retries: int = 4,
  53. ) -> None:
  54. self._transport = transport
  55. self._initial_interval = initial_interval
  56. self._randomization_factor = randomization_factor
  57. self._multiplier = multiplier
  58. self._max_interval = max_interval
  59. self._max_retries = max_retries
  60. @final
  61. async def execute(self, request: Request) -> Response:
  62. retry_mode = normalize_retry_mode(value=self.should_retry_request(request))
  63. if retry_mode is None:
  64. return await self._transport.execute(request)
  65. backoff = _Backoff(
  66. self._initial_interval,
  67. self._randomization_factor,
  68. self._multiplier,
  69. self._max_interval,
  70. )
  71. get_content: Callable[[], bytes | AsyncIterator[bytes]]
  72. content = request.content
  73. content_started = False
  74. unbuffered_stream = (
  75. not isinstance(content, bytes) and retry_mode == RetryMode.UNBUFFERED
  76. )
  77. async def _close_content() -> None:
  78. aclose = getattr(content, "aclose", None)
  79. if aclose is not None:
  80. await aclose()
  81. if isinstance(content, bytes):
  82. def _get_content() -> bytes:
  83. return content
  84. get_content = _get_content
  85. elif unbuffered_stream:
  86. async def _unbuffered_content() -> AsyncIterator[bytes]:
  87. nonlocal content_started
  88. content_started = True
  89. try:
  90. async for chunk in content:
  91. yield chunk
  92. finally:
  93. await _close_content()
  94. get_content = _unbuffered_content
  95. else:
  96. retrying_content = RetryingRequestContent(content)
  97. get_content = retrying_content.get
  98. resp: Response | Exception
  99. retries = 0
  100. # Retry connection errors regardless of retry mode.
  101. try:
  102. while True:
  103. try:
  104. resp = await self._transport.execute(
  105. Request(
  106. method=request.method,
  107. url=request.url,
  108. headers=request.headers,
  109. content=get_content(),
  110. )
  111. )
  112. except Exception as e: # noqa: PERF203
  113. if not self.should_retry_response(request, e):
  114. raise
  115. if unbuffered_stream and content_started:
  116. # I/O happened for an unbuffered stream, can't retry.
  117. raise
  118. resp = e
  119. retries += 1
  120. self._check_retries(retries, e)
  121. wait_time = backoff.next_backoff()
  122. if wait_time is None:
  123. raise
  124. await asyncio.sleep(wait_time)
  125. else:
  126. break
  127. except BaseException:
  128. if unbuffered_stream and not content_started:
  129. await _close_content()
  130. raise
  131. # Don't retry responses with a streaming request when we can't buffer.
  132. if unbuffered_stream:
  133. if not content_started:
  134. await _close_content()
  135. if isinstance(resp, Exception):
  136. raise resp
  137. return resp
  138. while True:
  139. if not self.should_retry_response(request, resp):
  140. break
  141. if isinstance(resp, Response):
  142. await resp.aclose()
  143. retries += 1
  144. self._check_retries(retries, resp)
  145. if (
  146. isinstance(resp, Response)
  147. and resp.status == HTTPStatus.TOO_MANY_REQUESTS
  148. and (
  149. wt := parse_retry_after(
  150. resp.headers.get(HTTPHeaderName.RETRY_AFTER)
  151. )
  152. )
  153. is not None
  154. ):
  155. wait_time = wt
  156. else:
  157. wait_time = backoff.next_backoff()
  158. if wait_time is None:
  159. break
  160. await asyncio.sleep(wait_time)
  161. try:
  162. resp = await self._transport.execute(
  163. Request(
  164. method=request.method,
  165. url=request.url,
  166. headers=request.headers,
  167. content=get_content(),
  168. )
  169. )
  170. except Exception as e:
  171. resp = e
  172. if isinstance(resp, Exception):
  173. raise resp
  174. return resp
  175. def should_retry_request(self, request: Request) -> bool | RetryMode:
  176. return default_should_retry_request(request.method)
  177. def should_retry_response(
  178. self, request: Request, response: Response | Exception
  179. ) -> bool:
  180. return default_should_retry_response(
  181. request.method,
  182. response.status if isinstance(response, Response) else response,
  183. )
  184. def _check_retries(self, retries: int, resp: Response | Exception) -> None:
  185. if retries > self._max_retries:
  186. if isinstance(resp, ConnectionError):
  187. # Connection errors that don't resolve with retries are better
  188. # surfaced as-is since they are network issues rather than backend.
  189. raise resp
  190. msg = f"Maximum retry attempts exceeded: {self._max_retries}"
  191. if isinstance(resp, Exception):
  192. raise ReadError(msg) from resp
  193. raise ReadError(msg)
  194. class RetryingRequestContent:
  195. def __init__(self, content: AsyncIterator[bytes]) -> None:
  196. self._content = content
  197. self._buffer = bytearray()
  198. async def get(self) -> AsyncIterator[bytes]:
  199. if self._buffer:
  200. yield bytes(self._buffer)
  201. async for chunk in self._content:
  202. self._buffer.extend(chunk)
  203. yield chunk