Repository navigation
refactor: common retry handling #472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| """ | ||
| The retry policy for feature fetches, shared by the sync and async clients. | ||
|
|
||
| Transports send each request once; retrying is decided here. Register and | ||
| metrics are never retried. | ||
| """ | ||
|
|
||
| import asyncio | ||
| import random | ||
| import time | ||
| from typing import Union | ||
|
|
||
| from UnleashClient._transport import AsyncTransport, Request, Response, Transport | ||
| from UnleashClient.errors import AlreadyClosedError | ||
| from UnleashClient.utils import LOGGER | ||
|
|
||
| RETRY_STATUSES = frozenset({500, 502, 504}) | ||
| BACKOFF_BASE = 0.5 | ||
|
|
||
| _sleep = time.sleep | ||
| _async_sleep = asyncio.sleep | ||
|
|
||
|
|
||
| def _backoff_delay(retry: int) -> float: | ||
| """ | ||
| Seconds to wait before retry number ``retry`` (0 for the first). | ||
|
|
||
| Exponential with jitter: a random point in the upper half of | ||
| ``BACKOFF_BASE * 2 ** retry``, so 0.25-0.5s, 0.5-1s, 1-2s, and clients that | ||
| failed together don't retry together. | ||
| """ | ||
| ceiling = BACKOFF_BASE * 2**retry | ||
| return random.uniform(ceiling / 2, ceiling) | ||
|
|
||
|
|
||
| _Outcome = Union[Response, Exception] | ||
|
|
||
|
|
||
| def _attempts(retries: int) -> range: | ||
| """The attempt numbers: the first attempt and ``retries`` retries.""" | ||
| return range(1 + max(retries, 0)) | ||
|
|
||
|
|
||
| def _should_retry(attempt: int, retries: int, outcome: _Outcome) -> bool: | ||
| if attempt >= max(retries, 0): | ||
| return False | ||
| if isinstance(outcome, Response): | ||
| return outcome.status in RETRY_STATUSES | ||
|
Comment on lines
+47
to
+48
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is actually true for the sync client, not the async, so not sure how much this was intented in the first place |
||
| return not isinstance(outcome, AlreadyClosedError) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we have no way of telling a transient error apart from a permanent one, since custom transport can use any lib they want might be worth adding a few non-retryable errors, i am think about adding it in a separate PR |
||
|
|
||
|
|
||
| def _next_step(attempt: int, retries: int, outcome: _Outcome) -> Union[Response, float]: | ||
| if not _should_retry(attempt, retries, outcome): | ||
| if isinstance(outcome, Exception): | ||
| raise outcome | ||
| return outcome | ||
|
|
||
| delay = _backoff_delay(attempt) | ||
| reason = ( | ||
| f"HTTP status {outcome.status}" | ||
| if isinstance(outcome, Response) | ||
| else repr(outcome) | ||
| ) | ||
| LOGGER.debug("Retrying feature fetch in %.2fs after %s", delay, reason) | ||
| return delay | ||
|
|
||
|
|
||
| def _send_with_retries( | ||
| transport: Transport, request: Request, retries: int | ||
| ) -> Response: | ||
| """ | ||
| Send ``request``, retrying on a status in :data:`RETRY_STATUSES` and on any | ||
| exception, with a backoff between attempts.ca | ||
|
|
||
| :param retries: Retries after the first attempt; negative counts as 0. | ||
| :return: The last response, which can still have a retry status. | ||
| :raises Exception: The last attempt's exception, if it raised. | ||
| """ | ||
| for attempt in _attempts(retries): | ||
| try: | ||
| outcome: _Outcome = transport.execute(request) | ||
| except Exception as exc: # pylint: disable=broad-except | ||
| outcome = exc | ||
| step = _next_step(attempt, retries, outcome) | ||
| if isinstance(step, Response): | ||
| return step | ||
| _sleep(step) | ||
|
|
||
| raise AssertionError("unreachable: the last attempt returns or raises") | ||
|
|
||
|
|
||
| async def _send_with_retries_async( | ||
| transport: AsyncTransport, request: Request, retries: int | ||
| ) -> Response: | ||
| """The asyncio twin of :func:`_send_with_retries`: only the awaits differ.""" | ||
| for attempt in _attempts(retries): | ||
| try: | ||
| outcome: _Outcome = await transport.execute(request) | ||
| except Exception as exc: # pylint: disable=broad-except | ||
| outcome = exc | ||
| step = _next_step(attempt, retries, outcome) | ||
| if isinstance(step, Response): | ||
| return step | ||
| await _async_sleep(step) | ||
|
|
||
| raise AssertionError("unreachable: the last attempt returns or raises") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i removed the option to configure retry count in a later PR, because I though retry should be the SDK's job, not sth the users decide
said that, we have no way of identifying is a custom transport implementation has retry mechanism, and then it can stack up with out implementation, but i would like to trust the SDK users to not mess that up
there is a warning for that in the readme, and also if they use our
RequestTransportclass we do log a warning about the duplicate retriesmaybe makes sense to allow the users to disable the built-in retry if they really need to, but I would wait for them to explicitly ask for it. then, I have a feeling that it might be better off in the polling options then in the transport