Skip to content

Commit 2142e4d

Browse files
new: Move retries over to HTTPAdapter-based approach (#283)
## 📝 Description This change moves the LinodeClient retry system over to an HTTPAdapter-based system. Additionally, this change introduces retires for `408` status responses. ## ✔️ How to Test ``` tox ``` --------- Co-authored-by: Zhiwei Liang <[email protected]>
1 parent 54c9adc commit 2142e4d

File tree

4 files changed

+174
-136
lines changed

4 files changed

+174
-136
lines changed

linode_api4/linode_client.py

+87-38
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
import json
44
import logging
5-
import time
65
from typing import BinaryIO, Tuple
76

87
import pkg_resources
98
import requests
9+
from requests.adapters import HTTPAdapter, Retry
1010

1111
from linode_api4.errors import ApiError, UnexpectedResponseError
1212
from linode_api4.groups import *
@@ -22,14 +22,27 @@
2222
logger = logging.getLogger(__name__)
2323

2424

25+
class LinearRetry(Retry):
26+
"""
27+
Linear retry is a subclass of Retry that uses a linear backoff strategy.
28+
This is necessary to maintain backwards compatibility with the old retry system.
29+
"""
30+
31+
def get_backoff_time(self):
32+
return self.backoff_factor
33+
34+
2535
class LinodeClient:
2636
def __init__(
2737
self,
2838
token,
2939
base_url="https://api.linode.com/v4",
3040
user_agent=None,
3141
page_size=None,
32-
retry_rate_limit_interval=None,
42+
retry=True,
43+
retry_rate_limit_interval=1.0,
44+
retry_max=5,
45+
retry_statuses=None,
3346
):
3447
"""
3548
The main interface to the Linode API.
@@ -51,26 +64,57 @@ def __init__(
5164
can be found in the API docs, but at time of writing
5265
are between 25 and 500.
5366
:type page_size: int
54-
:param retry_rate_limit_interval: If given, 429 responses will be automatically
55-
retried up to 5 times with the given interval,
56-
in seconds, between attempts.
57-
:type retry_rate_limit_interval: int
67+
:param retry: Whether API requests should automatically be retries on known
68+
intermittent responses.
69+
:type retry: bool
70+
:param retry_rate_limit_interval: The amount of time to wait between HTTP request
71+
retries.
72+
:type retry_rate_limit_interval: float
73+
:param retry_max: The number of request retries that should be attempted before
74+
raising an API error.
75+
:type retry_max: int
76+
:type retry_statuses: List of int
77+
:param retry_statuses: Additional HTTP response statuses to retry on.
78+
By default, the client will retry on 408, 429, and 502
79+
responses.
5880
"""
5981
self.base_url = base_url
6082
self._add_user_agent = user_agent
6183
self.token = token
62-
self.session = requests.Session()
6384
self.page_size = page_size
64-
self.retry_rate_limit_interval = retry_rate_limit_interval
85+
86+
retry_forcelist = [408, 429, 502]
87+
88+
if retry_statuses is not None:
89+
retry_forcelist.extend(retry_statuses)
6590

6691
# make sure we got a sane backoff
67-
if self.retry_rate_limit_interval is not None:
68-
if not isinstance(self.retry_rate_limit_interval, int):
69-
raise ValueError("retry_rate_limit_interval must be an int")
70-
if self.retry_rate_limit_interval < 1:
71-
raise ValueError(
72-
"retry_rate_limit_interval must not be less than 1"
73-
)
92+
if not isinstance(retry_rate_limit_interval, float):
93+
raise ValueError("retry_rate_limit_interval must be a float")
94+
95+
# Ensure the max retries value is valid
96+
if not isinstance(retry_max, int):
97+
raise ValueError("retry_max must be an int")
98+
99+
self.retry = retry
100+
self.retry_rate_limit_interval = retry_rate_limit_interval
101+
self.retry_max = retry_max
102+
self.retry_statuses = retry_statuses
103+
104+
# Initialize the HTTP client session
105+
self.session = requests.Session()
106+
107+
self._retry_config = LinearRetry(
108+
total=retry_max if retry else 0,
109+
status_forcelist=retry_forcelist,
110+
respect_retry_after_header=True,
111+
backoff_factor=retry_rate_limit_interval,
112+
raise_on_status=False,
113+
)
114+
retry_adapter = HTTPAdapter(max_retries=self._retry_config)
115+
116+
self.session.mount("http://", retry_adapter)
117+
self.session.mount("https://", retry_adapter)
74118

75119
#: Access methods related to Linodes - see :any:`LinodeGroup` for
76120
#: more information
@@ -196,29 +240,11 @@ def _api_call(
196240
if data is not None:
197241
body = json.dumps(data)
198242

199-
# retry on 429 response
200-
max_retries = 5 if self.retry_rate_limit_interval else 1
201-
for attempt in range(max_retries):
202-
response = method(url, headers=headers, data=body)
203-
204-
warning = response.headers.get("Warning", None)
205-
if warning:
206-
logger.warning(
207-
"Received warning from server: {}".format(warning)
208-
)
209-
210-
# if we were configured to retry 429s, and we got a 429, sleep briefly and then retry
211-
if self.retry_rate_limit_interval and response.status_code == 429:
212-
logger.warning(
213-
"Received 429 response; waiting {} seconds and retrying request (attempt {}/{})".format(
214-
self.retry_rate_limit_interval,
215-
attempt,
216-
max_retries,
217-
)
218-
)
219-
time.sleep(self.retry_rate_limit_interval)
220-
else:
221-
break
243+
response = method(url, headers=headers, data=body)
244+
245+
warning = response.headers.get("Warning", None)
246+
if warning:
247+
logger.warning("Received warning from server: {}".format(warning))
222248

223249
if 399 < response.status_code < 600:
224250
j = None
@@ -288,6 +314,29 @@ def put(self, *args, **kwargs):
288314
def delete(self, *args, **kwargs):
289315
return self._api_call(*args, method=self.session.delete, **kwargs)
290316

317+
def __setattr__(self, key, value):
318+
# Allow for dynamic updating of the retry config
319+
handlers = {
320+
"retry_rate_limit_interval": lambda: setattr(
321+
self._retry_config, "backoff_factor", value
322+
),
323+
"retry": lambda: setattr(
324+
self._retry_config, "total", self.retry_max if value else 0
325+
),
326+
"retry_max": lambda: setattr(
327+
self._retry_config, "total", value if self.retry else 0
328+
),
329+
"retry_statuses": lambda: setattr(
330+
self._retry_config, "status_forcelist", value
331+
),
332+
}
333+
334+
handler = handlers.get(key)
335+
if hasattr(self, "_retry_config") and handler is not None:
336+
handler()
337+
338+
super().__setattr__(key, value)
339+
291340
def image_create(self, disk, label=None, description=None):
292341
"""
293342
.. note:: This method is an alias to maintain backwards compatibility.

requirements-dev.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ mock>=5.0.0
66
tox>=4.4.0
77
Sphinx>=6.0.0
88
sphinx-autobuild>=2021.3.14
9-
sphinxcontrib-fulltoc>=1.2.0
9+
sphinxcontrib-fulltoc>=1.2.0
10+
httpretty>=1.1.4

0 commit comments

Comments
 (0)