Skip to content

Commit 4e7e329

Browse files
committed
🚧 draft(feat): Retry logic on applicative errors
1 parent 91595ee commit 4e7e329

9 files changed

Lines changed: 371 additions & 105 deletions

File tree

‎osc_sdk_python/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .version import get_version
77
from .problem import Problem, ProblemDecoder
88
from .limiter import RateLimiter
9+
from .retry import Retry
910

1011
# what to Log
1112
from .outscale_gateway import LOG_ALL
@@ -26,4 +27,5 @@
2627
"Problem",
2728
"ProblemDecoder",
2829
"RateLimiter",
30+
"Retry",
2931
]

‎osc_sdk_python/call.py‎

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,13 @@
33
from .credentials import Profile
44
from .requester import Requester
55
from requests import Session
6-
from requests.adapters import HTTPAdapter
7-
from urllib3.util.retry import Retry
86
from urllib3.util import parse_url
97
from datetime import timedelta
108
from .limiter import RateLimiter
119

1210
import json
1311
import warnings
1412

15-
MAX_RETRIES = 3
16-
RETRY_BACKOFF_FACTOR = "1"
17-
RETRY_BACKOFF_JITTER = "3"
18-
RETRY_BACKOFF_MAX = "30"
19-
2013

2114
class Call(object):
2215
def __init__(self, logger=None, limiter=None, **kwargs):
@@ -26,15 +19,10 @@ def __init__(self, logger=None, limiter=None, **kwargs):
2619
self.user_agent = kwargs.pop("user_agent", DEFAULT_USER_AGENT)
2720
self.logger = logger
2821
self.limiter: RateLimiter | None = limiter
29-
self.adapter = None
3022
self.session = Session()
3123

3224
kwargs = self.update_limiter(**kwargs)
33-
kwargs = self.update_adapter(**kwargs)
3425
self.update_profile(**kwargs)
35-
if self.adapter:
36-
self.session.mount("https://", self.adapter)
37-
self.session.mount("http://", self.adapter)
3826

3927
def update_credentials(self, **kwargs):
4028
warnings.warn(
@@ -44,32 +32,6 @@ def update_credentials(self, **kwargs):
4432
)
4533
return self.update_profile(**kwargs)
4634

47-
def update_adapter(self, **kwargs):
48-
max_retries: int | str | None = kwargs.pop("max_retries", None)
49-
if max_retries is not None:
50-
max_retries = int(max_retries)
51-
else:
52-
max_retries = MAX_RETRIES
53-
54-
if max_retries > 0:
55-
self.adapter = HTTPAdapter(
56-
max_retries=Retry(
57-
total=max_retries,
58-
backoff_factor=float(
59-
kwargs.pop("retry_backoff_factor", RETRY_BACKOFF_FACTOR)
60-
),
61-
backoff_jitter=float(
62-
kwargs.pop("retry_backoff_jitter", RETRY_BACKOFF_JITTER)
63-
),
64-
backoff_max=float(
65-
kwargs.pop("retry_backoff_max", RETRY_BACKOFF_MAX)
66-
),
67-
status_forcelist=(400, 429, 500, 503),
68-
allowed_methods=("POST", "GET"),
69-
)
70-
)
71-
return kwargs
72-
7335
def update_profile(self, **kwargs):
7436
self.profile = Profile.from_standard_configuration(
7537
kwargs.pop("path", None), kwargs.pop("profile", None)
Lines changed: 8 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
from requests import HTTPError
2-
from requests.exceptions import JSONDecodeError
3-
from .problem import ProblemDecoder, LegacyProblemDecoder, LegacyProblem, Problem
1+
from .retry import Retry
42

53

64
class Requester:
7-
def __init__(
8-
self,
9-
session,
10-
auth,
11-
endpoint,
12-
):
5+
def __init__(self, session, auth, endpoint, **kwargs):
136
self.session = session
147
self.auth = auth
158
self.endpoint = endpoint
9+
self.request_kwargs = kwargs
1610

1711
def send(self, uri, payload):
1812
headers = None
@@ -26,57 +20,10 @@ def send(self, uri, payload):
2620
else:
2721
cert_file = None
2822

29-
response = self.session.post(
30-
self.endpoint,
31-
data=payload,
32-
headers=headers,
33-
verify=True,
34-
cert=cert_file,
23+
retry_kwargs = self.request_kwargs.copy()
24+
retry_kwargs.update(
25+
{"data": payload, "headers": headers, "verify": True, "cert": cert_file}
3526
)
36-
self.raise_for_status(response)
37-
return response.json()
3827

39-
def raise_for_status(self, response):
40-
http_error_msg = ""
41-
problem = None
42-
reason = self.get_default_reason(response)
43-
44-
try:
45-
ct = response.headers.get("content-type") or ""
46-
if "application/json" in ct:
47-
problem = response.json(cls=LegacyProblemDecoder)
48-
problem.status = problem.status or str(response.status_code)
49-
problem.url = response.url
50-
elif "application/problem+json" in ct:
51-
problem = response.json(cls=ProblemDecoder)
52-
problem.status = problem.status or str(response.status_code)
53-
except JSONDecodeError:
54-
pass
55-
else:
56-
if 400 <= response.status_code < 500:
57-
if isinstance(problem, LegacyProblem) or isinstance(problem, Problem):
58-
http_error_msg = f"Client Error --> {problem.msg()}"
59-
else:
60-
http_error_msg = f"{response.status_code} Client Error: {reason} for url: {response.url}"
61-
62-
elif 500 <= response.status_code < 600:
63-
if isinstance(problem, LegacyProblem) or isinstance(problem, Problem):
64-
http_error_msg = f"Server Error --> {problem.msg()}"
65-
else:
66-
http_error_msg = f"{response.status_code} Server Error: {reason} for url: {response.url}"
67-
68-
if http_error_msg:
69-
raise HTTPError(http_error_msg, response=response)
70-
71-
def get_default_reason(self, response):
72-
if isinstance(response.reason, bytes):
73-
# We attempt to decode utf-8 first because some servers
74-
# choose to localize their reason strings. If the string
75-
# isn't utf-8, we fall back to iso-8859-1 for all other
76-
# encodings. (See PR #3538)
77-
try:
78-
return response.reason.decode("utf-8")
79-
except UnicodeDecodeError:
80-
return response.reason.decode("iso-8859-1")
81-
else:
82-
return response.reason
28+
response = Retry(self.session, "post", self.endpoint, **retry_kwargs)
29+
return response.execute().json()

‎osc_sdk_python/retry.py‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import requests
2+
import time
3+
import random
4+
from requests.exceptions import JSONDecodeError
5+
from .problem import ProblemDecoder, LegacyProblemDecoder, LegacyProblem, Problem
6+
7+
MAX_RETRIES = 3
8+
RETRY_BACKOFF_FACTOR = 1.0
9+
RETRY_BACKOFF_JITTER = 3.0
10+
RETRY_BACKOFF_MAX = 30.0
11+
12+
13+
class Retry:
14+
"""
15+
Hold a request attempt and try to execute it
16+
"""
17+
18+
def __init__(self, session: requests.Session, method: str, url: str, **kwargs):
19+
self.session = session
20+
self.method: str = method
21+
self.url: str = url
22+
self.request_kwargs = kwargs
23+
24+
# Extract all retry parameters
25+
self.attempt: int = int(self.request_kwargs.pop("attempt", 0))
26+
self.max_retries: float = int(
27+
self.request_kwargs.get("max_retries", MAX_RETRIES)
28+
)
29+
self.backoff_factor: float = float(
30+
self.request_kwargs.get("backoff_factor", RETRY_BACKOFF_FACTOR)
31+
)
32+
self.backoff_jitter: float = float(
33+
self.request_kwargs.get("backoff_jitter", RETRY_BACKOFF_JITTER)
34+
)
35+
self.backoff_max: float = float(
36+
self.request_kwargs.get("backoff_max", RETRY_BACKOFF_MAX)
37+
)
38+
39+
def execute_once(self) -> requests.Response:
40+
"""
41+
Execute the request without retry
42+
"""
43+
return self.session.request(self.method, self.url, **self.request_kwargs)
44+
45+
def increment(self) -> "Retry":
46+
"""
47+
Return a copy of the retry with an incremented attempt count
48+
"""
49+
new_kwargs = self.request_kwargs.copy()
50+
new_kwargs["attempt"] = self.attempt + 1
51+
return Retry(self.session, self.method, self.url, **new_kwargs)
52+
53+
def should_retry(self, e: requests.exceptions.RequestException) -> bool:
54+
if isinstance(e, requests.exceptions.TooManyRedirects):
55+
return False
56+
57+
if isinstance(e, requests.exceptions.URLRequired):
58+
return False
59+
60+
if isinstance(e, ValueError):
61+
# can be raised on bogus request
62+
return False
63+
64+
if e.response is not None:
65+
if 400 <= e.response.status_code < 500 and e.response.status_code != 429:
66+
return False
67+
68+
return self.attempt < self.max_retries
69+
70+
def get_backoff_time(self) -> float:
71+
"""
72+
{backoff factor} * (2 ** ({number of previous retries}))
73+
random.uniform(0, {backoff jitter})
74+
"""
75+
76+
backoff: float = self.backoff_factor * (2**self.attempt)
77+
backoff += random.uniform(0, self.backoff_jitter)
78+
return min(backoff, self.backoff_max)
79+
80+
def execute(self) -> requests.Response:
81+
try:
82+
res = self.execute_once()
83+
raise_for_status(res)
84+
return res
85+
except requests.exceptions.RequestException as e:
86+
if self.should_retry(e):
87+
sleep_time = self.get_backoff_time()
88+
time.sleep(sleep_time)
89+
return self.increment().execute()
90+
else:
91+
raise e
92+
93+
94+
def raise_for_status(response: requests.Response):
95+
http_error_msg = ""
96+
problem = None
97+
reason = get_default_reason(response)
98+
99+
try:
100+
ct = response.headers.get("content-type") or ""
101+
if "application/json" in ct:
102+
problem = response.json(cls=LegacyProblemDecoder)
103+
problem.status = problem.status or str(response.status_code)
104+
problem.url = response.url
105+
elif "application/problem+json" in ct:
106+
problem = response.json(cls=ProblemDecoder)
107+
problem.status = problem.status or str(response.status_code)
108+
except JSONDecodeError:
109+
pass
110+
else:
111+
if 400 <= response.status_code < 500:
112+
if isinstance(problem, LegacyProblem) or isinstance(problem, Problem):
113+
http_error_msg = f"Client Error --> {problem.msg()}"
114+
else:
115+
http_error_msg = f"{response.status_code} Client Error: {reason} for url: {response.url}"
116+
117+
elif 500 <= response.status_code < 600:
118+
if isinstance(problem, LegacyProblem) or isinstance(problem, Problem):
119+
http_error_msg = f"Server Error --> {problem.msg()}"
120+
else:
121+
http_error_msg = f"{response.status_code} Server Error: {reason} for url: {response.url}"
122+
123+
if http_error_msg:
124+
raise requests.HTTPError(http_error_msg, response=response)
125+
126+
127+
def get_default_reason(response):
128+
if isinstance(response.reason, bytes):
129+
# We attempt to decode utf-8 first because some servers
130+
# choose to localize their reason strings. If the string
131+
# isn't utf-8, we fall back to iso-8859-1 for all other
132+
# encodings. (See PR #3538)
133+
try:
134+
return response.reason.decode("utf-8")
135+
except UnicodeDecodeError:
136+
return response.reason.decode("iso-8859-1")
137+
else:
138+
return response.reason

‎tests/test_exceptions.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33

44
sys.path.append("..")
55
from osc_sdk_python import Gateway
6-
from requests.exceptions import RetryError
6+
from requests.exceptions import HTTPError
77

88

99
class TestExcept(unittest.TestCase):
10-
1110
def test_listing(self):
1211
gw = Gateway()
1312
# a is not a valide argument
14-
with self.assertRaises(RetryError):
13+
with self.assertRaises(HTTPError):
1514
gw.ReadVms(Filters="a")
1615

1716

‎tests/test_log.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77

88
class TestLog(unittest.TestCase):
9-
109
def test_listing(self):
1110
gw = Gateway()
1211
gw.log.config(type=LOG_MEMORY, what=LOG_KEEP_ONLY_LAST_REQ)

0 commit comments

Comments
 (0)