Skip to content

Commit 4ccba29

Browse files
feat: introduce request retries to sync client (#9)
* feat: introduce request retries to sync client * fix: remove debug print * fix: make retries less amibigious to the original request * docs: update docstring
1 parent afad48f commit 4ccba29

5 files changed

Lines changed: 111 additions & 29 deletions

File tree

autumn/aio/client.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919

2020
if TYPE_CHECKING:
2121
from typing_extensions import Self
22-
from .shed import AttachParams, CheckParams, TrackParams, CheckoutParams, QueryParams
22+
from .shed import (
23+
AttachParams,
24+
CheckParams,
25+
TrackParams,
26+
CheckoutParams,
27+
QueryParams,
28+
)
2329

2430
__all__ = ("AsyncClient",)
2531

@@ -59,8 +65,8 @@ class AsyncClient(Client):
5965
attach: AttachParams # type: ignore
6066
check: CheckParams # type: ignore
6167
track: TrackParams # type: ignore
62-
checkout: CheckoutParams # type: ignore
63-
query: QueryParams # type: ignore
68+
checkout: CheckoutParams # type: ignore
69+
query: QueryParams # type: ignore
6470

6571
def __init__(
6672
self,
@@ -75,8 +81,14 @@ def __init__(
7581
_base_url = base_url or BASE_URL
7682
_base_url = _base_url.rstrip("/")
7783

84+
attempts = max_retries + 1
7885
self.http = AsyncHTTPClient(
79-
_base_url, VERSION, token, max_retries=max_retries, session=session)
86+
_base_url,
87+
VERSION,
88+
token,
89+
attempts=attempts,
90+
session=session,
91+
)
8092
self.customers = Customers(self.http)
8193
self.features = Features(self.http)
8294
self.products = Products(self.http)

autumn/aio/http.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
from pydantic import BaseModel
66

77
from ..error import AutumnError, AutumnHTTPError
8-
from ..http import HTTPClient
9-
from ..utils import _build_model, _check_response
8+
from ..http import HTTPClient, _RetryRequestError
9+
from ..utils import _build_model, _check_response, ExponentialBackoff
1010

1111

1212
try:
@@ -23,39 +23,33 @@
2323
__all__ = ("AsyncHTTPClient",)
2424

2525

26-
class _RetryRequestError(Exception):
27-
pass
28-
29-
3026
class AsyncHTTPClient:
3127
def __init__(
3228
self,
3329
base_url: str,
3430
version: str,
3531
token: str,
36-
max_retries: int = 3,
32+
attempts: int,
3733
*,
3834
session: Optional[aiohttp.ClientSession] = None
3935
):
4036
self.base_url = base_url
4137
self.version = version
4238
self.session = session # type: ignore
4339
self._headers = HTTPClient._build_headers(token)
44-
self.max_retries = max_retries
40+
self.attempts = attempts
4541

4642
self._build_url = HTTPClient._build_url
4743

48-
rand = random.Random()
49-
rand.seed()
50-
self._rand = rand
51-
5244
async def request(self, method: str, path: str, type_: Type[T], **kwargs) -> T:
5345
if self.session is None:
5446
self.session = aiohttp.ClientSession()
5547

5648
url = self._build_url(self.base_url, self.version, path)
5749

58-
for attempt in range(self.max_retries):
50+
max_attempts = self.attempts
51+
backoff = ExponentialBackoff()
52+
for attempt in range(max_attempts):
5953
try:
6054
async with self.session.request(
6155
method, url, headers=self._headers, **kwargs
@@ -66,12 +60,16 @@ async def request(self, method: str, path: str, type_: Type[T], **kwargs) -> T:
6660
data = await resp.json()
6761

6862
except (_RetryRequestError, OSError, asyncio.TimeoutError):
69-
sleep_time = (2 ** attempt) + self._rand.uniform(0, 1)
70-
await asyncio.sleep(sleep_time)
63+
if attempt == max_attempts - 1:
64+
raise
65+
66+
await asyncio.sleep(backoff.bedtime)
67+
backoff.tick()
7168
else:
7269
_check_response(resp.status, data)
7370
return _build_model(type_, data)
74-
71+
72+
# We should never get here. This is to appease type checkers.
7573
msg = f"Max retries reached for {method} {path}"
7674
raise AutumnHTTPError(msg, "max_retries_reached", 500)
7775

autumn/client.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ async def main():
6565
----------
6666
token: str
6767
The API key to use for authentication.
68+
max_retries: int
69+
The maximum number of retries to attempt for failed requests.
6870
base_url: Optional[str]
6971
The base URL of the Autumn API. This is useful when you are self-hosting Autumn and need to point to your own instance.
7072
@@ -78,13 +80,20 @@ async def main():
7880
An interface to Autumn's product API.
7981
"""
8082

81-
def __init__(self, token: str, *, base_url: Optional[str] = None):
83+
def __init__(
84+
self,
85+
token: str,
86+
*,
87+
base_url: Optional[str] = None,
88+
max_retries: int = 5,
89+
):
8290
from . import BASE_URL, VERSION
8391

8492
_base_url = base_url or BASE_URL
8593
_base_url = _base_url.rstrip("/")
8694

87-
self.http = HTTPClient(_base_url, VERSION, token)
95+
attempts = max_retries + 1 # account for the original request
96+
self.http = HTTPClient(_base_url, VERSION, token, attempts=attempts)
8897
self.customers = Customers(self.http)
8998
self.features = Features(self.http)
9099
self.products = Products(self.http)

autumn/http.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,40 @@
11
import sys
2+
import time
3+
import random
24
from typing import Dict, Type, TypeVar
35

46
import requests
57
from pydantic import BaseModel
68

7-
from .utils import _build_model, _check_response
8-
from .error import AutumnError
9+
from .utils import _build_model, _check_response, ExponentialBackoff
10+
from .error import AutumnError, AutumnHTTPError
911

1012
__all__ = ("HTTPClient",)
1113

1214
T = TypeVar("T", bound=BaseModel)
1315

1416

17+
class _RetryRequestError(Exception):
18+
pass
19+
1520
class HTTPClient:
16-
def __init__(self, base_url: str, version: str, token: str):
21+
def __init__(
22+
self,
23+
base_url: str,
24+
version: str,
25+
token: str,
26+
attempts: int
27+
):
1728
self.base_url = base_url
1829
self.version = version
1930
self.session = requests.Session()
2031

2132
self._headers = self._build_headers(token)
33+
self.attempts = attempts
34+
35+
rand = random.Random()
36+
rand.seed()
37+
self._rand = rand
2238

2339
@staticmethod
2440
def _build_url(base_url: str, version: str, path: str) -> str:
@@ -56,12 +72,39 @@ def request(
5672
)
5773

5874
url = self._build_url(self.base_url, self.version, path)
59-
resp = self.session.request(method, url, headers=self._headers, **kwargs)
6075

61-
data = resp.json()
76+
max_attempts = self.attempts
77+
backoff = ExponentialBackoff()
78+
for attempt in range(max_attempts):
79+
try:
80+
resp = self.session.request(
81+
method,
82+
url,
83+
headers=self._headers,
84+
**kwargs
85+
)
86+
if 500 <= resp.status_code <= 504:
87+
raise _RetryRequestError()
88+
89+
data = resp.json()
90+
except (
91+
_RetryRequestError,
92+
OSError,
93+
requests.ConnectionError,
94+
requests.ConnectTimeout
95+
):
96+
if attempt == max_attempts - 1:
97+
raise
98+
99+
time.sleep(backoff.bedtime)
100+
backoff.tick()
101+
else:
102+
_check_response(resp.status_code, data)
103+
return _build_model(type_, data)
62104

63-
_check_response(resp.status_code, data)
64-
return _build_model(type_, data)
105+
# We should never get here. This is to appease type checkers.
106+
msg = f"Max retries reached for {method} {path}"
107+
raise AutumnHTTPError(msg, "max_retries_reached", 500)
65108

66109
def close(self):
67110
if self.session is not None:

autumn/utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import random
12
from typing import Dict, Any, Callable, Type, TypeVar, Set
23

34
from pydantic import BaseModel, ValidationError
@@ -61,3 +62,22 @@ def _check_response(status_code: int, data: Dict[str, Any]) -> None:
6162
code,
6263
status_code,
6364
)
65+
66+
67+
class ExponentialBackoff:
68+
def __init__(self):
69+
rand = random.Random()
70+
rand.seed()
71+
72+
self._rand = rand
73+
self._base = 2
74+
self._state = 0
75+
76+
def tick(self):
77+
self._state += 1
78+
79+
@property
80+
def bedtime(self):
81+
raw_time = self._base ** self._state
82+
jitter = self._rand.uniform(0, 1)
83+
return raw_time + jitter

0 commit comments

Comments
 (0)