_sync.py 7.9 KB

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