_async.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  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. default_should_retry_request,
  9. default_should_retry_response,
  10. parse_retry_after,
  11. )
  12. if TYPE_CHECKING:
  13. from collections.abc import AsyncIterator, Callable
  14. class RetryTransport(Transport):
  15. """Retry middleware for async clients.
  16. Wrap a Transport with this class to allow requests to be automatically retried.
  17. By default, known-safe errors are retried, meaning connection errors for any request,
  18. and I/O errors or 429/5xx responses for idempotent methods.
  19. The default behavior can be overridden by subclassing this class and overriding the
  20. `should_retry_request` and `should_retry_response` methods to suit any need.
  21. Examples:
  22. ```python
  23. from pyqwest import Client, HTTPTransport, Request
  24. from pyqwest.middleware.retry import RetryTransport
  25. class MyRetryTransport(RetryTransport):
  26. def should_retry_request(self, request: Request) -> bool:
  27. return not request.url.endswith("/unsafe-method")
  28. client = Client(transport=MyRetryTransport(HTTPTransport()))
  29. await client.get(
  30. "http://localhost/safe-method"
  31. ) # will retry on transient errors
  32. await client.get("http://localhost/unsafe-method") # will not retry
  33. ```
  34. """
  35. _transport: Transport
  36. _initial_interval: float
  37. _randomization_factor: float
  38. _multiplier: float
  39. _max_interval: float
  40. _max_retries: int
  41. def __init__(
  42. self,
  43. transport: Transport,
  44. initial_interval: float = 0.5,
  45. randomization_factor: float = 0.5,
  46. multiplier: float = 1.5,
  47. max_interval: float = 60.0,
  48. max_retries: int = 4,
  49. ) -> None:
  50. self._transport = transport
  51. self._initial_interval = initial_interval
  52. self._randomization_factor = randomization_factor
  53. self._multiplier = multiplier
  54. self._max_interval = max_interval
  55. self._max_retries = max_retries
  56. @final
  57. async def execute(self, request: Request) -> Response:
  58. if not self.should_retry_request(request):
  59. return await self._transport.execute(request)
  60. backoff = _Backoff(
  61. self._initial_interval,
  62. self._randomization_factor,
  63. self._multiplier,
  64. self._max_interval,
  65. )
  66. get_content: Callable[[], bytes | AsyncIterator[bytes]]
  67. content = request.content
  68. if isinstance(content, bytes):
  69. def _get_content() -> bytes:
  70. return content
  71. get_content = _get_content
  72. else:
  73. retrying_content = RetryingRequestContent(content)
  74. get_content = retrying_content.get
  75. resp: Response | Exception
  76. try:
  77. resp = await self._transport.execute(
  78. Request(
  79. method=request.method,
  80. url=request.url,
  81. headers=request.headers,
  82. content=get_content(),
  83. )
  84. )
  85. except Exception as e:
  86. resp = e
  87. retries = 0
  88. while True:
  89. if not self.should_retry_response(request, resp):
  90. break
  91. if isinstance(resp, Response):
  92. await resp.aclose()
  93. retries += 1
  94. if retries > self._max_retries:
  95. if isinstance(resp, ConnectionError):
  96. # Connection errors that don't resolve with retries are better
  97. # surfaced as-is since they are network issues rather than backend.
  98. raise resp
  99. msg = f"Maximum retry attempts exceeded: {self._max_retries}"
  100. if isinstance(resp, Exception):
  101. raise ReadError(msg) from resp
  102. raise ReadError(msg)
  103. if (
  104. isinstance(resp, Response)
  105. and resp.status == HTTPStatus.TOO_MANY_REQUESTS
  106. and (
  107. wt := parse_retry_after(
  108. resp.headers.get(HTTPHeaderName.RETRY_AFTER)
  109. )
  110. )
  111. is not None
  112. ):
  113. wait_time = wt
  114. else:
  115. wait_time = backoff.next_backoff()
  116. if wait_time is None:
  117. break
  118. await asyncio.sleep(wait_time)
  119. try:
  120. resp = await self._transport.execute(
  121. Request(
  122. method=request.method,
  123. url=request.url,
  124. headers=request.headers,
  125. content=get_content(),
  126. )
  127. )
  128. except Exception as e:
  129. resp = e
  130. if isinstance(resp, Exception):
  131. raise resp
  132. return resp
  133. def should_retry_request(self, request: Request) -> bool:
  134. return default_should_retry_request(request.method)
  135. def should_retry_response(
  136. self, request: Request, response: Response | Exception
  137. ) -> bool:
  138. return default_should_retry_response(
  139. request.method,
  140. response.status if isinstance(response, Response) else response,
  141. )
  142. class RetryingRequestContent:
  143. def __init__(self, content: AsyncIterator[bytes]) -> None:
  144. self._content = content
  145. self._buffer = bytearray()
  146. async def get(self) -> AsyncIterator[bytes]:
  147. if self._buffer:
  148. yield bytes(self._buffer)
  149. async for chunk in self._content:
  150. self._buffer.extend(chunk)
  151. yield chunk