class RetryMiddleware:
__slots__ = (
'_backoff_factor',
'_jitter',
'_logger',
'_max_attempts',
'_max_delay',
'_max_retry_after',
'_respect_retry_after',
'_retry_methods',
'_status_codes',
)
def __init__(
self,
*,
max_attempts: int = 3,
backoff_factor: float = 0.5,
max_delay: float = 30.0,
respect_retry_after: bool = True,
max_retry_after: float | None = None,
jitter: bool = True,
status_codes: Iterable[int] = (408, 429, 500, 502, 503, 504),
retry_methods: Iterable[str] = (
'GET',
'HEAD',
'OPTIONS',
'PUT',
'DELETE',
),
logger: Logger | None = None,
) -> None:
super().__init__()
self._logger = logger or getLogger('oxidehttp-retry')
self._max_attempts = max_attempts
self._backoff_factor = backoff_factor
self._max_delay = max_delay
self._respect_retry_after = respect_retry_after
self._max_retry_after = max_retry_after
self._jitter = jitter
self._status_codes = frozenset(status_codes)
self._retry_methods = frozenset(retry_methods)
if self._max_attempts < 1:
exc = 'max_attempts must be at least 1'
raise ValueError(exc)
if self._backoff_factor < 0:
exc = 'backoff_factor cannot be negative'
raise ValueError(exc)
if self._max_delay <= 0:
exc = 'max_delay must be greater than zero'
raise ValueError(exc)
@staticmethod
def _get_jittered_delay(delay: float) -> float:
if delay <= 0:
return 0.0
# 1 ms resolution.
upper = max(1, int(delay * 1000))
return randbelow(upper + 1) / 1000
def _should_retry(
self, response: Response, method: str, attempt: int
) -> bool:
if attempt >= self._max_attempts:
return False
if method not in self._retry_methods:
return False
return response.status in self._status_codes
def _parse_retry_after(self, response: Response) -> float | None:
value = response.headers.get('Retry-After')
if value is None:
return None
value = value.strip()
if not value:
return None
try:
return float(int(value))
except ValueError:
pass
try:
retry_at = parsedate_to_datetime(value).replace(tzinfo=UTC)
except TypeError, ValueError, OverflowError:
return None
delay = (retry_at - datetime.now(UTC)).total_seconds()
return max(0.0, delay)
def _get_retry_delay(
self, attempt: int, response: Response | None = None
) -> float:
if self._respect_retry_after and response is not None:
retry_after = self._parse_retry_after(response)
if retry_after is not None:
if self._max_retry_after is not None:
return min(retry_after, self._max_retry_after)
return retry_after
exponential = float(self._backoff_factor * (2 ** (attempt - 1)))
delay = min(exponential, self._max_delay)
if self._jitter:
delay = self._get_jittered_delay(delay)
return delay
async def run(self, request: Request, next_req: Next) -> Response:
method = request.method
for attempt in range(1, self._max_attempts + 1):
try:
response = await next_req.run(request)
except PyreqwestError as e:
if attempt >= self._max_attempts:
raise
delay = self._get_retry_delay(attempt)
await sleep(delay)
self._logger.warning(
'Retrying HTTP request',
extra={
'method': method,
'url': str(request.url),
'attempt': attempt,
'error': str(e),
'delay': delay,
},
)
continue
if not self._should_retry(response, method, attempt):
return response
delay = self._get_retry_delay(attempt, response)
await sleep(delay)
self._logger.warning(
'Retrying HTTP request',
extra={
'method': method,
'url': str(request.url),
'attempt': attempt,
'delay': delay,
},
)
exc = 'Retry loop exited unexpectedly'
raise RuntimeError(exc)