Skip to content

Commit f3f5e58

Browse files
authored
[Rest] Support exponential backoff and retry with urllib3 < 2 and new retry parameters (#1492)
1 parent 8f06e79 commit f3f5e58

File tree

1 file changed

+113
-32
lines changed

1 file changed

+113
-32
lines changed

atlassian/rest_client.py

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# coding=utf-8
22
import logging
3+
import random
34
from json import dumps
45

56
import requests
@@ -9,12 +10,13 @@
910
from oauthlib.oauth1.rfc5849 import SIGNATURE_RSA_SHA512 as SIGNATURE_RSA
1011
except ImportError:
1112
from oauthlib.oauth1 import SIGNATURE_RSA
13+
import time
14+
15+
import urllib3
1216
from requests import HTTPError
1317
from requests_oauthlib import OAuth1, OAuth2
1418
from six.moves.urllib.parse import urlencode
15-
import time
1619
from urllib3.util import Retry
17-
import urllib3
1820

1921
from atlassian.request_utils import get_default_logger
2022

@@ -69,6 +71,9 @@ def __init__(
6971
retry_status_codes=[413, 429, 503],
7072
max_backoff_seconds=1800,
7173
max_backoff_retries=1000,
74+
backoff_factor=1.0,
75+
backoff_jitter=1.0,
76+
retry_with_header=True,
7277
):
7378
"""
7479
init function for the AtlassianRestAPI object.
@@ -102,6 +107,19 @@ def __init__(
102107
wait any longer than this. Defaults to 1800.
103108
:param max_backoff_retries: Maximum number of retries to try before
104109
continuing. Defaults to 1000.
110+
:param backoff_factor: Factor by which to multiply the backoff time (for exponential backoff).
111+
Defaults to 1.0.
112+
:param backoff_jitter: Random variation to add to the backoff time to avoid synchronized retries.
113+
Defaults to 1.0.
114+
:param retry_with_header: Enable retry logic based on the `Retry-After` header.
115+
If set to True, the request will automatically retry if the response
116+
contains a `Retry-After` header with a delay and has a status code of 429. The retry delay will be extracted
117+
from the `Retry-After` header and the request will be paused for the specified
118+
duration before retrying. Defaults to True.
119+
If the `Retry-After` header is not present, retries will not occur.
120+
However, if the `Retry-After` header is missing and `backoff_and_retry` is enabled,
121+
the retry logic will still be triggered based on the status code 429,
122+
provided that 429 is included in the `retry_status_codes` list.
105123
"""
106124
self.url = url
107125
self.username = username
@@ -115,6 +133,14 @@ def __init__(
115133
self.cloud = cloud
116134
self.proxies = proxies
117135
self.cert = cert
136+
self.backoff_and_retry = backoff_and_retry
137+
self.max_backoff_retries = max_backoff_retries
138+
self.retry_status_codes = retry_status_codes
139+
self.max_backoff_seconds = max_backoff_seconds
140+
self.use_urllib3_retry = int(urllib3.__version__.split(".")[0]) >= 2
141+
self.backoff_factor = backoff_factor
142+
self.backoff_jitter = backoff_jitter
143+
self.retry_with_header = retry_with_header
118144
if session is None:
119145
self._session = requests.Session()
120146
else:
@@ -123,17 +149,17 @@ def __init__(
123149
if proxies is not None:
124150
self._session.proxies = self.proxies
125151

126-
if backoff_and_retry and int(urllib3.__version__.split(".")[0]) >= 2:
152+
if self.backoff_and_retry and self.use_urllib3_retry:
127153
# Note: we only retry on status and not on any of the
128154
# other supported reasons
129155
retries = Retry(
130156
total=None,
131-
status=max_backoff_retries,
157+
status=self.max_backoff_retries,
132158
allowed_methods=None,
133-
status_forcelist=retry_status_codes,
134-
backoff_factor=1,
135-
backoff_jitter=1,
136-
backoff_max=max_backoff_seconds,
159+
status_forcelist=self.retry_status_codes,
160+
backoff_factor=self.backoff_factor,
161+
backoff_jitter=self.backoff_jitter,
162+
backoff_max=self.max_backoff_seconds,
137163
)
138164
self._session.mount(self.url, HTTPAdapter(max_retries=retries))
139165
if username and password:
@@ -209,6 +235,59 @@ def _response_handler(response):
209235
log.error(e)
210236
return None
211237

238+
def _calculate_backoff_value(self, retry_count):
239+
"""
240+
Calculate the backoff delay for a given retry attempt.
241+
242+
This method computes an exponential backoff delay based on the retry count and
243+
a configurable backoff factor. It optionally adds a random jitter to introduce
244+
variability in the delay, which can help prevent synchronized retries in
245+
distributed systems. The calculated backoff delay is clamped between 0 and a
246+
maximum allowable delay (`self.max_backoff_seconds`) to avoid excessively long
247+
wait times.
248+
249+
:param retry_count: int, REQUIRED: The current retry attempt number (1-based).
250+
Determines the exponential backoff delay.
251+
:return: float: The calculated backoff delay in seconds, adjusted for jitter
252+
and clamped to the maximum allowable value.
253+
"""
254+
backoff_value = self.backoff_factor * (2 ** (retry_count - 1))
255+
if self.backoff_jitter != 0.0:
256+
backoff_value += random.random() * self.backoff_jitter
257+
return float(max(0, min(self.max_backoff_seconds, backoff_value)))
258+
259+
def _retry_handler(self):
260+
"""
261+
Creates and returns a retry handler function for managing HTTP request retries.
262+
263+
The returned handler function determines whether a request should be retried
264+
based on the response and retry settings.
265+
266+
:return: Callable[[Response], bool]: A function that takes an HTTP response object as input and
267+
returns `True` if the request should be retried, or `False` otherwise.
268+
"""
269+
retries = 0
270+
271+
def _handle(response):
272+
nonlocal retries
273+
274+
if self.retry_with_header and "Retry-After" in response.headers and response.status_code == 429:
275+
time.sleep(int(response.headers["Retry-After"]))
276+
return True
277+
278+
if not self.backoff_and_retry or self.use_urllib3_retry:
279+
return False
280+
281+
if retries < self.max_backoff_retries and response.status_code in self.retry_status_codes:
282+
retries += 1
283+
backoff_value = self._calculate_backoff_value(retries)
284+
time.sleep(backoff_value)
285+
return True
286+
287+
return False
288+
289+
return _handle
290+
212291
def log_curl_debug(self, method, url, data=None, headers=None, level=logging.DEBUG):
213292
"""
214293
@@ -274,30 +353,32 @@ def request(
274353
:param advanced_mode: bool, OPTIONAL: Return the raw response
275354
:return:
276355
"""
356+
url = self.url_joiner(None if absolute else self.url, path, trailing)
357+
params_already_in_url = True if "?" in url else False
358+
if params or flags:
359+
if params_already_in_url:
360+
url += "&"
361+
else:
362+
url += "?"
363+
if params:
364+
url += urlencode(params or {})
365+
if flags:
366+
url += ("&" if params or params_already_in_url else "") + "&".join(flags or [])
367+
json_dump = None
368+
if files is None:
369+
data = None if not data else dumps(data)
370+
json_dump = None if not json else dumps(json)
371+
372+
headers = headers or self.default_headers
277373

374+
retry_handler = self._retry_handler()
278375
while True:
279-
url = self.url_joiner(None if absolute else self.url, path, trailing)
280-
params_already_in_url = True if "?" in url else False
281-
if params or flags:
282-
if params_already_in_url:
283-
url += "&"
284-
else:
285-
url += "?"
286-
if params:
287-
url += urlencode(params or {})
288-
if flags:
289-
url += ("&" if params or params_already_in_url else "") + "&".join(flags or [])
290-
json_dump = None
291-
if files is None:
292-
data = None if not data else dumps(data)
293-
json_dump = None if not json else dumps(json)
294376
self.log_curl_debug(
295377
method=method,
296378
url=url,
297379
headers=headers,
298-
data=data if data else json_dump,
380+
data=data or json_dump,
299381
)
300-
headers = headers or self.default_headers
301382
response = self._session.request(
302383
method=method,
303384
url=url,
@@ -310,15 +391,15 @@ def request(
310391
proxies=self.proxies,
311392
cert=self.cert,
312393
)
313-
response.encoding = "utf-8"
394+
continue_retries = retry_handler(response)
395+
if continue_retries:
396+
continue
397+
break
314398

315-
log.debug("HTTP: %s %s -> %s %s", method, path, response.status_code, response.reason)
316-
log.debug("HTTP: Response text -> %s", response.text)
399+
response.encoding = "utf-8"
317400

318-
if response.status_code == 429:
319-
time.sleep(int(response.headers["Retry-After"]))
320-
else:
321-
break
401+
log.debug("HTTP: %s %s -> %s %s", method, path, response.status_code, response.reason)
402+
log.debug("HTTP: Response text -> %s", response.text)
322403

323404
if self.advanced_mode or advanced_mode:
324405
return response

0 commit comments

Comments
 (0)