Skip to content

Commit 7bba084

Browse files
committed
[Rest] Support exponential backoff and retry with urllib3 < 2 and new retry parameters
1 parent 85eea47 commit 7bba084

File tree

1 file changed

+111
-32
lines changed

1 file changed

+111
-32
lines changed

atlassian/rest_client.py

Lines changed: 111 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,57 @@ 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 value based on the retry count.
243+
Optionally, it adds a random jitter to introduce variability in the delay
244+
to prevent synchronized retries in distributed systems. The backoff value is
245+
clamped between 0 and a maximum allowed delay (`self.max_backoff_seconds`).
246+
247+
:param retry_count: int, REQUIRED: The current retry attempt number (1-based).
248+
Determines the exponential backoff delay.
249+
:return: float: The calculated backoff delay in seconds, adjusted for jitter
250+
and clamped to the maximum allowable value.
251+
"""
252+
backoff_value = 2 ** (retry_count - 1)
253+
if self.backoff_jitter != 0.0:
254+
backoff_value += random.random() * self.backoff_jitter
255+
return float(max(0, min(self.max_backoff_seconds, backoff_value)))
256+
257+
def _retry_handler(self):
258+
"""
259+
Creates and returns a retry handler function for managing HTTP request retries.
260+
261+
The returned handler function determines whether a request should be retried
262+
based on the response and retry settings.
263+
264+
:return: Callable[[Response], bool]: A function that takes an HTTP response object as input and
265+
returns `True` if the request should be retried, or `False` otherwise.
266+
"""
267+
retries = 0
268+
269+
def _handle(response):
270+
nonlocal retries
271+
272+
if self.retry_with_header and "Retry-After" in response.headers and response.status_code == 429:
273+
time.sleep(int(response.headers["Retry-After"]))
274+
return True
275+
276+
if not self.backoff_and_retry or self.use_urllib3_retry:
277+
return False
278+
279+
if retries < self.max_backoff_retries and response.status_code in self.retry_status_codes:
280+
retries += 1
281+
backoff_value = self._calculate_backoff_value(retries)
282+
time.sleep(backoff_value)
283+
return True
284+
285+
return False
286+
287+
return _handle
288+
212289
def log_curl_debug(self, method, url, data=None, headers=None, level=logging.DEBUG):
213290
"""
214291
@@ -274,30 +351,32 @@ def request(
274351
:param advanced_mode: bool, OPTIONAL: Return the raw response
275352
:return:
276353
"""
354+
url = self.url_joiner(None if absolute else self.url, path, trailing)
355+
params_already_in_url = True if "?" in url else False
356+
if params or flags:
357+
if params_already_in_url:
358+
url += "&"
359+
else:
360+
url += "?"
361+
if params:
362+
url += urlencode(params or {})
363+
if flags:
364+
url += ("&" if params or params_already_in_url else "") + "&".join(flags or [])
365+
json_dump = None
366+
if files is None:
367+
data = None if not data else dumps(data)
368+
json_dump = None if not json else dumps(json)
369+
370+
headers = headers or self.default_headers
277371

372+
retry_handler = self._retry_handler()
278373
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)
294374
self.log_curl_debug(
295375
method=method,
296376
url=url,
297377
headers=headers,
298-
data=data if data else json_dump,
378+
data=data or json_dump,
299379
)
300-
headers = headers or self.default_headers
301380
response = self._session.request(
302381
method=method,
303382
url=url,
@@ -310,15 +389,15 @@ def request(
310389
proxies=self.proxies,
311390
cert=self.cert,
312391
)
313-
response.encoding = "utf-8"
392+
continue_retries = retry_handler(response)
393+
if continue_retries:
394+
continue
395+
break
314396

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

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

323402
if self.advanced_mode or advanced_mode:
324403
return response

0 commit comments

Comments
 (0)