Skip to content

Commit 4f88eab

Browse files
committed
Add configurable retry behavior with reasonable defaults for count, wait, backoff, and jitter
This makes retry behavior consistent between sync and async clients, and in its default configuration will get users past network interruptions.
1 parent 8d32203 commit 4f88eab

File tree

4 files changed

+182
-68
lines changed

4 files changed

+182
-68
lines changed

indico/config/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class IndicoConfig:
2222
api_token= (str, optional): The actual text of the API Token. Takes precedence over api_token_path
2323
verify_ssl= (bool, optional): Whether to verify the host's SSL certificate. Default=True
2424
requests_params= (dict, optional): Dictionary of requests. Session parameters to set
25+
retry_count= (int, optional): Retry API calls this many times.
26+
retry_wait= (float, optional): Wait this many seconds after the first error before retrying.
27+
retry_backoff= (float, optional): Multiply the wait time by this amount for each additional error.
28+
retry_jitter= (float, optional): Add a random amount of time (up to this percent as a decimal) to the wait time to prevent simultaneous retries.
2529
2630
Returns:
2731
IndicoConfig object
@@ -42,6 +46,11 @@ def __init__(self, **kwargs: "Any"):
4246
self.requests_params: "Optional[AnyDict]" = None
4347
self._disable_cookie_domain: bool = False
4448

49+
self.retry_count: int = int(os.getenv("INDICO_retry_count", "4"))
50+
self.retry_wait: float = float(os.getenv("INDICO_retry_wait", "1"))
51+
self.retry_backoff: float = float(os.getenv("INDICO_retry_backoff", "4"))
52+
self.retry_jitter: float = float(os.getenv("INDICO_retry_jitter", "1"))
53+
4554
for key, value in kwargs.items():
4655
if hasattr(self, key):
4756
setattr(self, key, value)

indico/http/client.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
)
1818
from indico.http.serialization import aio_deserialize, deserialize
1919

20-
from .retry import aioretry
20+
from .retry import retry
2121

2222
if TYPE_CHECKING: # pragma: no cover
2323
from http.cookiejar import Cookie
@@ -50,6 +50,14 @@ class HTTPClient:
5050
def __init__(self, config: "Optional[IndicoConfig]" = None):
5151
self.config = config or IndicoConfig()
5252
self.base_url = f"{self.config.protocol}://{self.config.host}"
53+
self._decorate_with_retry = retry(
54+
requests.RequestException,
55+
count=self.config.retry_count,
56+
wait=self.config.retry_wait,
57+
backoff=self.config.retry_backoff,
58+
jitter=self.config.retry_jitter,
59+
)
60+
self._make_request = self._decorate_with_retry(self._make_request) # type: ignore[method-assign]
5361

5462
self.request_session = requests.Session()
5563
if isinstance(self.config.requests_params, dict):
@@ -232,6 +240,14 @@ def __init__(self, config: "Optional[IndicoConfig]" = None):
232240
"""
233241
self.config = config or IndicoConfig()
234242
self.base_url = f"{self.config.protocol}://{self.config.host}"
243+
self._decorate_with_retry = retry(
244+
aiohttp.ClientConnectionError,
245+
count=self.config.retry_count,
246+
wait=self.config.retry_wait,
247+
backoff=self.config.retry_backoff,
248+
jitter=self.config.retry_jitter,
249+
)
250+
self._make_request = self._decorate_with_retry(self._make_request) # type: ignore[method-assign]
235251

236252
self.request_session = aiohttp.ClientSession()
237253
if isinstance(self.config.requests_params, dict):
@@ -316,7 +332,6 @@ def _handle_files(
316332
for f in files:
317333
f.close()
318334

319-
@aioretry(aiohttp.ClientConnectionError, aiohttp.ServerDisconnectedError)
320335
async def _make_request(
321336
self,
322337
method: str,

indico/http/retry.py

Lines changed: 83 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,102 @@
11
import asyncio
22
import time
33
from functools import wraps
4-
from random import randint
5-
from typing import TYPE_CHECKING
4+
from inspect import iscoroutinefunction
5+
from random import random
6+
from typing import TYPE_CHECKING, overload
67

7-
if TYPE_CHECKING: # pragma: no cover
8-
from typing import Awaitable, Callable, Optional, Tuple, Type, TypeVar, Union
8+
if TYPE_CHECKING:
9+
import sys
10+
from collections.abc import Awaitable, Callable
911

10-
from typing_extensions import ParamSpec
12+
if sys.version_info >= (3, 10):
13+
from typing import ParamSpec, TypeVar
14+
else:
15+
from typing import Type
16+
from typing_extensions import ParamSpec, TypeVar
1117

12-
P = ParamSpec("P")
13-
T = TypeVar("T")
18+
type = Type
19+
20+
ArgumentsType = ParamSpec("ArgumentsType")
21+
OuterReturnType = TypeVar("OuterReturnType")
22+
InnerReturnType = TypeVar("InnerReturnType")
1423

1524

1625
def retry(
17-
*ExceptionTypes: "Type[Exception]", tries: int = 3, delay: int = 1, backoff: int = 2
18-
) -> "Callable[[Callable[P, T]], Callable[P, T]]":
26+
*errors: "type[Exception]",
27+
count: int,
28+
wait: float,
29+
backoff: float,
30+
jitter: float,
31+
) -> "Callable[[Callable[ArgumentsType, OuterReturnType]], Callable[ArgumentsType, OuterReturnType]]": # noqa: E501
1932
"""
20-
Retry with exponential backoff
33+
Decorate a function or coroutine to retry when it raises specified errors,
34+
apply exponential backoff and jitter to the wait time,
35+
and raise the last error if it retries too many times.
2136
22-
Original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry
37+
Arguments:
38+
errors: Retry the function when it raises one of these errors.
39+
count: Retry the function this many times.
40+
wait: Wait this many seconds after the first error before retrying.
41+
backoff: Multiply the wait time by this amount for each additional error.
42+
jitter: Add a random amount of time (up to this percent as a decimal)
43+
to the wait time to prevent simultaneous retries.
2344
"""
2445

25-
def retry_decorator(f: "Callable[P, T]") -> "Callable[P, T]":
26-
@wraps(f)
27-
def retry_fn(*args: "P.args", **kwargs: "P.kwargs") -> "T":
28-
n_tries, n_delay = tries, delay
29-
while n_tries > 1:
30-
try:
31-
return f(*args, **kwargs)
32-
except ExceptionTypes:
33-
time.sleep(n_delay)
34-
n_tries -= 1
35-
n_delay *= backoff
36-
return f(*args, **kwargs)
37-
38-
return retry_fn
46+
def wait_time(times_retried: int) -> float:
47+
"""
48+
Calculate the sleep time based on number of times retried.
49+
"""
50+
return wait * backoff**times_retried * (1 + jitter * random())
3951

40-
return retry_decorator
52+
@overload
53+
def retry_decorator(
54+
decorated: "Callable[ArgumentsType, Awaitable[InnerReturnType]]",
55+
) -> "Callable[ArgumentsType, Awaitable[InnerReturnType]]": ...
4156

57+
@overload
58+
def retry_decorator(
59+
decorated: "Callable[ArgumentsType, InnerReturnType]",
60+
) -> "Callable[ArgumentsType, InnerReturnType]": ...
4261

43-
def aioretry(
44-
*ExceptionTypes: "Type[Exception]",
45-
tries: int = 3,
46-
delay: "Union[int, Tuple[int, int]]" = 1,
47-
backoff: int = 2,
48-
condition: "Optional[Callable[[Exception], bool]]" = None,
49-
) -> "Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]":
50-
"""
51-
Retry with exponential backoff
52-
53-
Original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry
54-
Options:
55-
condition: Callable to evaluate if an exception of a given type
56-
is retryable for additional handling
57-
delay: an initial time to wait (seconds). If a tuple, choose a random number
58-
in that range to start. This can helps prevent retries at the exact
59-
same time across multiple concurrent function calls
60-
"""
62+
def retry_decorator(
63+
decorated: "Callable[ArgumentsType, InnerReturnType]",
64+
) -> "Callable[ArgumentsType, Awaitable[InnerReturnType]] | Callable[ArgumentsType, InnerReturnType]": # noqa: E501
65+
"""
66+
Decorate either a function or coroutine as appropriate.
67+
"""
68+
if iscoroutinefunction(decorated):
69+
70+
@wraps(decorated)
71+
async def retrying_coroutine( # type: ignore[return]
72+
*args: "ArgumentsType.args", **kwargs: "ArgumentsType.kwargs"
73+
) -> "InnerReturnType":
74+
for times_retried in range(count + 1):
75+
try:
76+
return await decorated(*args, **kwargs) # type: ignore[no-any-return]
77+
except errors:
78+
if times_retried >= count:
79+
raise
80+
81+
await asyncio.sleep(wait_time(times_retried))
82+
83+
return retrying_coroutine
84+
85+
else:
86+
87+
@wraps(decorated)
88+
def retrying_function( # type: ignore[return]
89+
*args: "ArgumentsType.args", **kwargs: "ArgumentsType.kwargs"
90+
) -> "InnerReturnType":
91+
for times_retried in range(count + 1):
92+
try:
93+
return decorated(*args, **kwargs)
94+
except errors:
95+
if times_retried >= count:
96+
raise
97+
98+
time.sleep(wait_time(times_retried))
6199

62-
def retry_decorator(f: "Callable[P, Awaitable[T]]") -> "Callable[P, Awaitable[T]]":
63-
@wraps(f)
64-
async def retry_fn(*args: "P.args", **kwargs: "P.kwargs") -> "T":
65-
n_tries = tries
66-
if isinstance(delay, tuple):
67-
# pick a random number to sleep
68-
n_delay = randint(*delay)
69-
else:
70-
n_delay = delay
71-
while True:
72-
try:
73-
return await f(*args, **kwargs)
74-
except ExceptionTypes as e:
75-
if condition and not condition(e):
76-
raise
77-
await asyncio.sleep(n_delay)
78-
n_tries -= 1
79-
n_delay *= backoff
80-
if n_tries <= 0:
81-
raise
82-
83-
return retry_fn
100+
return retrying_function
84101

85102
return retry_decorator

tests/unit/http/test_retry.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import pytest
2+
3+
from indico.http.retry import retry
4+
5+
6+
def test_no_errors() -> None:
7+
@retry(Exception, count=0, wait=0, backoff=0, jitter=0)
8+
def no_errors() -> bool:
9+
return True
10+
11+
assert no_errors()
12+
13+
14+
def test_raises_errors() -> None:
15+
calls = 0
16+
17+
@retry(RuntimeError, count=4, wait=0, backoff=0, jitter=0)
18+
def raises_errors() -> None:
19+
nonlocal calls
20+
calls += 1
21+
raise RuntimeError()
22+
23+
with pytest.raises(RuntimeError):
24+
raises_errors()
25+
26+
assert calls == 5
27+
28+
29+
def test_raises_other_errors() -> None:
30+
calls = 0
31+
32+
@retry(RuntimeError, count=4, wait=0, backoff=0, jitter=0)
33+
def raises_errors() -> None:
34+
nonlocal calls
35+
calls += 1
36+
raise ValueError()
37+
38+
with pytest.raises(ValueError):
39+
raises_errors()
40+
41+
assert calls == 1
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_raises_errors_async() -> None:
46+
calls = 0
47+
48+
@retry(RuntimeError, count=4, wait=0, backoff=0, jitter=0)
49+
async def raises_errors() -> None:
50+
nonlocal calls
51+
calls += 1
52+
raise RuntimeError()
53+
54+
with pytest.raises(RuntimeError):
55+
await raises_errors()
56+
57+
assert calls == 5
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_raises_other_errors_async() -> None:
62+
calls = 0
63+
64+
@retry(RuntimeError, count=4, wait=0, backoff=0, jitter=0)
65+
async def raises_errors() -> None:
66+
nonlocal calls
67+
calls += 1
68+
raise ValueError()
69+
70+
with pytest.raises(ValueError):
71+
await raises_errors()
72+
73+
assert calls == 1

0 commit comments

Comments
 (0)