-
Notifications
You must be signed in to change notification settings - Fork 1
[DEV-12952] Add Configurable Retry Behavior #350
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
Open
mawelborn
wants to merge
3
commits into
master
Choose a base branch
from
mawelborn/configurable-retry
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,85 +1,100 @@ | ||
| import asyncio | ||
| import time | ||
| from functools import wraps | ||
| from random import randint | ||
| from typing import TYPE_CHECKING | ||
| from inspect import iscoroutinefunction | ||
| from random import random | ||
| from typing import TYPE_CHECKING, overload | ||
|
|
||
| if TYPE_CHECKING: # pragma: no cover | ||
| from typing import Awaitable, Callable, Optional, Tuple, Type, TypeVar, Union | ||
| if TYPE_CHECKING: | ||
| import sys | ||
| from collections.abc import Awaitable, Callable | ||
| from typing import Type | ||
|
|
||
| from typing_extensions import ParamSpec | ||
| if sys.version_info >= (3, 10): | ||
| from typing import ParamSpec, TypeVar | ||
| else: | ||
| from typing_extensions import ParamSpec, TypeVar | ||
|
|
||
| P = ParamSpec("P") | ||
| T = TypeVar("T") | ||
| ArgumentsType = ParamSpec("ArgumentsType") | ||
| OuterReturnType = TypeVar("OuterReturnType") | ||
| InnerReturnType = TypeVar("InnerReturnType") | ||
|
|
||
|
|
||
| def retry( | ||
| *ExceptionTypes: "Type[Exception]", tries: int = 3, delay: int = 1, backoff: int = 2 | ||
| ) -> "Callable[[Callable[P, T]], Callable[P, T]]": | ||
| *errors: "Type[Exception]", | ||
| count: int, | ||
| wait: float, | ||
| backoff: float, | ||
| jitter: float, | ||
| ) -> "Callable[[Callable[ArgumentsType, OuterReturnType]], Callable[ArgumentsType, OuterReturnType]]": # noqa: E501 | ||
| """ | ||
| Retry with exponential backoff | ||
| Decorate a function or coroutine to retry when it raises specified errors, | ||
| apply exponential backoff and jitter to the wait time, | ||
| and raise the last error if it retries too many times. | ||
|
|
||
| Original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry | ||
| Arguments: | ||
| errors: Retry the function when it raises one of these errors. | ||
| count: Retry the function this many times. | ||
| wait: Wait this many seconds after the first error before retrying. | ||
| backoff: Multiply the wait time by this amount for each additional error. | ||
| jitter: Add a random amount of time (up to this percent as a decimal) | ||
| to the wait time to prevent simultaneous retries. | ||
| """ | ||
|
|
||
| def retry_decorator(f: "Callable[P, T]") -> "Callable[P, T]": | ||
| @wraps(f) | ||
| def retry_fn(*args: "P.args", **kwargs: "P.kwargs") -> "T": | ||
| n_tries, n_delay = tries, delay | ||
| while n_tries > 1: | ||
| try: | ||
| return f(*args, **kwargs) | ||
| except ExceptionTypes: | ||
| time.sleep(n_delay) | ||
| n_tries -= 1 | ||
| n_delay *= backoff | ||
| return f(*args, **kwargs) | ||
|
|
||
| return retry_fn | ||
| def wait_time(times_retried: int) -> float: | ||
| """ | ||
| Calculate the sleep time based on number of times retried. | ||
| """ | ||
| return wait * backoff**times_retried * (1 + jitter * random()) | ||
|
|
||
| return retry_decorator | ||
| @overload | ||
| def retry_decorator( | ||
| decorated: "Callable[ArgumentsType, Awaitable[InnerReturnType]]", | ||
| ) -> "Callable[ArgumentsType, Awaitable[InnerReturnType]]": ... | ||
|
|
||
| @overload | ||
| def retry_decorator( | ||
| decorated: "Callable[ArgumentsType, InnerReturnType]", | ||
| ) -> "Callable[ArgumentsType, InnerReturnType]": ... | ||
|
|
||
| def aioretry( | ||
| *ExceptionTypes: "Type[Exception]", | ||
| tries: int = 3, | ||
| delay: "Union[int, Tuple[int, int]]" = 1, | ||
| backoff: int = 2, | ||
| condition: "Optional[Callable[[Exception], bool]]" = None, | ||
| ) -> "Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]": | ||
| """ | ||
| Retry with exponential backoff | ||
|
|
||
| Original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry | ||
| Options: | ||
| condition: Callable to evaluate if an exception of a given type | ||
| is retryable for additional handling | ||
| delay: an initial time to wait (seconds). If a tuple, choose a random number | ||
| in that range to start. This can helps prevent retries at the exact | ||
| same time across multiple concurrent function calls | ||
| """ | ||
| def retry_decorator( | ||
| decorated: "Callable[ArgumentsType, InnerReturnType]", | ||
| ) -> "Callable[ArgumentsType, Awaitable[InnerReturnType]] | Callable[ArgumentsType, InnerReturnType]": # noqa: E501 | ||
| """ | ||
| Decorate either a function or coroutine as appropriate. | ||
| """ | ||
| if iscoroutinefunction(decorated): | ||
|
|
||
| @wraps(decorated) | ||
| async def retrying_coroutine( # type: ignore[return] | ||
| *args: "ArgumentsType.args", **kwargs: "ArgumentsType.kwargs" | ||
| ) -> "InnerReturnType": | ||
| for times_retried in range(count + 1): | ||
| try: | ||
| return await decorated(*args, **kwargs) # type: ignore[no-any-return] | ||
| except errors: | ||
| if times_retried >= count: | ||
| raise | ||
|
|
||
| await asyncio.sleep(wait_time(times_retried)) | ||
|
|
||
| return retrying_coroutine | ||
|
|
||
| else: | ||
|
|
||
| @wraps(decorated) | ||
| def retrying_function( # type: ignore[return] | ||
| *args: "ArgumentsType.args", **kwargs: "ArgumentsType.kwargs" | ||
| ) -> "InnerReturnType": | ||
| for times_retried in range(count + 1): | ||
| try: | ||
| return decorated(*args, **kwargs) | ||
| except errors: | ||
| if times_retried >= count: | ||
| raise | ||
|
|
||
| time.sleep(wait_time(times_retried)) | ||
|
|
||
| def retry_decorator(f: "Callable[P, Awaitable[T]]") -> "Callable[P, Awaitable[T]]": | ||
| @wraps(f) | ||
| async def retry_fn(*args: "P.args", **kwargs: "P.kwargs") -> "T": | ||
| n_tries = tries | ||
| if isinstance(delay, tuple): | ||
| # pick a random number to sleep | ||
| n_delay = randint(*delay) | ||
| else: | ||
| n_delay = delay | ||
| while True: | ||
| try: | ||
| return await f(*args, **kwargs) | ||
| except ExceptionTypes as e: | ||
| if condition and not condition(e): | ||
| raise | ||
| await asyncio.sleep(n_delay) | ||
| n_tries -= 1 | ||
| n_delay *= backoff | ||
| if n_tries <= 0: | ||
| raise | ||
|
|
||
| return retry_fn | ||
| return retrying_function | ||
|
|
||
| return retry_decorator |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import pytest | ||
|
|
||
| from indico.http.retry import retry | ||
|
|
||
|
|
||
| def test_no_errors() -> None: | ||
| @retry(Exception, count=0, wait=0, backoff=0, jitter=0) | ||
| def no_errors() -> bool: | ||
| return True | ||
|
|
||
| assert no_errors() | ||
|
|
||
|
|
||
| def test_raises_errors() -> None: | ||
| calls = 0 | ||
|
|
||
| @retry(RuntimeError, count=4, wait=0, backoff=0, jitter=0) | ||
| def raises_errors() -> None: | ||
| nonlocal calls | ||
| calls += 1 | ||
| raise RuntimeError() | ||
|
|
||
| with pytest.raises(RuntimeError): | ||
| raises_errors() | ||
|
|
||
| assert calls == 5 | ||
|
|
||
|
|
||
| def test_raises_other_errors() -> None: | ||
| calls = 0 | ||
|
|
||
| @retry(RuntimeError, count=4, wait=0, backoff=0, jitter=0) | ||
| def raises_errors() -> None: | ||
| nonlocal calls | ||
| calls += 1 | ||
| raise ValueError() | ||
|
|
||
| with pytest.raises(ValueError): | ||
| raises_errors() | ||
|
|
||
| assert calls == 1 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_raises_errors_async() -> None: | ||
| calls = 0 | ||
|
|
||
| @retry(RuntimeError, count=4, wait=0, backoff=0, jitter=0) | ||
| async def raises_errors() -> None: | ||
| nonlocal calls | ||
| calls += 1 | ||
| raise RuntimeError() | ||
|
|
||
| with pytest.raises(RuntimeError): | ||
| await raises_errors() | ||
|
|
||
| assert calls == 5 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_raises_other_errors_async() -> None: | ||
| calls = 0 | ||
|
|
||
| @retry(RuntimeError, count=4, wait=0, backoff=0, jitter=0) | ||
| async def raises_errors() -> None: | ||
| nonlocal calls | ||
| calls += 1 | ||
| raise ValueError() | ||
|
|
||
| with pytest.raises(ValueError): | ||
| await raises_errors() | ||
|
|
||
| assert calls == 1 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.