Skip to content

Commit a082749

Browse files
authored
Merge pull request SUSE#172 from SUSE/urllib3-retry
Use `urllib3.Retry` instead of custom implementation
2 parents 3b46445 + 6ad7e47 commit a082749

6 files changed

Lines changed: 130 additions & 62 deletions

File tree

‎doc/source/api.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,8 @@ Utilities
8585
.. automodule:: osctiny.utils.mapping
8686
:members:
8787

88+
.. automodule:: osctiny.utils.session
89+
:members:
90+
8891
.. automodule:: osctiny.utils.xml
8992
:members:

‎osctiny/osc.py‎

Lines changed: 23 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,15 @@
44
"""
55
from __future__ import unicode_literals
66

7-
from base64 import b64encode
87
import typing
98
import errno
109
from http.cookiejar import CookieJar, LWPCookieJar
1110
from io import BufferedReader, BytesIO, StringIO
1211
import gc
13-
import logging
14-
import os
1512
from pathlib import Path
1613
import re
17-
from ssl import get_default_verify_paths
18-
import time
1914
import threading
20-
from urllib.parse import quote, parse_qs, urlparse
15+
from urllib.parse import quote, urlparse
2116
import warnings
2217

2318
from lxml.etree import tostring
@@ -45,6 +40,7 @@
4540
from .utils.conf import BOOLEAN_PARAMS, get_credentials
4641
from .utils.cookies import CookieManager
4742
from .utils.errors import OscError
43+
from .utils.session import generate_session_id, init_session, RetryPolicy
4844
from .utils.xml import get_xml_parser, get_objectified_xml
4945

5046

@@ -132,6 +128,10 @@ class Osc:
132128
.. versionchanged:: 0.9.0
133129
* Added the ``staging`` extension
134130
131+
.. versionchanged:: {{ NEXT_RELEASE }}
132+
* Deprecated ``default_connection_retries`` and ``default_retry_timeout``
133+
* Introduced :py:class:`osctiny.utils.session.RetryPolicy`
134+
135135
.. _SSL Cert Verification:
136136
http://docs.python-requests.org/en/master/user/advanced/
137137
#ssl-cert-verification
@@ -142,6 +142,8 @@ class Osc:
142142
default_timeout = (60, 300)
143143
default_connection_retries = 5
144144
default_retry_timeout = 5
145+
retry_policy = RetryPolicy(max_attempts=default_connection_retries,
146+
backoff_max=default_retry_timeout)
145147

146148
def __init__(self, url: typing.Optional[str] = None, username: typing.Optional[str] = None,
147149
password: typing.Optional[str] = None, verify: typing.Optional[str] = None,
@@ -180,29 +182,21 @@ def __del__(self):
180182
# Just in case ;-)
181183
gc.collect()
182184

183-
@property
184-
def _session_id(self) -> str:
185-
session_hash = b64encode(f'{self.username}@{self.url}'.encode()).decode()
186-
return f"session_{session_hash}_{os.getpid()}_{threading.get_ident()}"
187-
188185
@property
189186
def session(self) -> Session:
190187
"""
191188
Session object
192189
"""
193-
session = getattr(THREAD_LOCAL, self._session_id, None)
190+
session_id = generate_session_id(username=self.username, url=self.url)
191+
session = getattr(THREAD_LOCAL, session_id, None)
194192
if not session:
195-
session = Session()
196-
session.verify = self.verify or get_default_verify_paths().capath
197-
session.cookies = CookieManager.get_jar()
198-
199193
if self.ssh_key is not None:
200-
session.auth = HttpSignatureAuth(username=self.username, password=self.password,
201-
ssh_key_file=self.ssh_key)
194+
auth = HttpSignatureAuth(username=self.username, password=self.password,
195+
ssh_key_file=self.ssh_key)
202196
else:
203-
session.auth = HTTPBasicAuth(self.username, self.password)
204-
205-
setattr(THREAD_LOCAL, self._session_id, session)
197+
auth = HTTPBasicAuth(self.username, self.password)
198+
session = init_session(auth=auth, policy=self.retry_policy, verify=self.verify)
199+
setattr(THREAD_LOCAL, session_id, session)
206200

207201
return session
208202

@@ -305,39 +299,14 @@ def request(self, url: str, method: str = "GET", stream: bool = False,
305299
if timeout:
306300
settings["timeout"] = timeout
307301

308-
logger = logging.getLogger("osctiny.request")
309-
310-
for i in range(self.default_connection_retries, -1, -1):
311-
logger.info("Requested URL: %s", prepped_req.url)
312-
logger.debug("Sent data:\n%s\n---",
313-
"\n".join(f"{k}: {v}" for k, v in req.data.items())
314-
if isinstance(req.data, dict) else req.data)
315-
logger.debug("Sent parameters:\n%s\n---",
316-
"\n".join(f"{k}: {v}" for k, v in (
317-
req.params
318-
if isinstance(req.params, dict)
319-
else parse_qs(req.params, keep_blank_values=True)
320-
).items()))
321-
try:
322-
response = self.session.send(prepped_req, **settings)
323-
except _ConnectionError as error:
324-
warnings.warn("Problem connecting to server: {}".format(error))
325-
log_method = logger.error if i < 1 else logger.warning
326-
log_method("Request failed: %s", error)
327-
if i < 1:
328-
raise
329-
logger.debug("Retrying request in %d seconds", self.default_retry_timeout)
330-
time.sleep(self.default_retry_timeout)
331-
else:
332-
logger.info("Server replied with status %d", response.status_code)
333-
logger.debug("Response headers:\n%s\n---",
334-
"\n".join(f"{k}: {v}" for k, v in response.headers.items()))
335-
if not stream:
336-
# The response content must not be accessed when streaming
337-
logger.debug("Response content:\n%s\n---", response.text)
338-
if raise_for_status:
339-
response.raise_for_status()
340-
return response
302+
try:
303+
response = self.session.send(prepped_req, **settings)
304+
except _ConnectionError as error:
305+
warnings.warn("Problem connecting to server: {}".format(error))
306+
else:
307+
if raise_for_status:
308+
response.raise_for_status()
309+
return response
341310

342311
return None
343312

‎osctiny/tests/base.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,7 @@ def setUpClass(cls):
6161
username="foobar",
6262
password="helloworld",
6363
)
64-
cls.osc.default_connection_retries = 0
65-
cls.osc.default_retry_timeout = 0
64+
cls.osc.retry_policy = None
6665

6766
@staticmethod
6867
def mock_request(**kwargs):

‎osctiny/tests/test_utils.py‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ..utils.cookies import CookieManager
2828
from ..utils.mapping import Mappable
2929
from ..utils.errors import get_http_error_details
30+
from ..utils.session import generate_session_id
3031

3132
sys.path.append(os.path.dirname(__file__))
3233

@@ -405,8 +406,7 @@ def test_get_credentials(self, *_):
405406
class TestAuth(TestCase):
406407
def _clear_thread_local(self):
407408
try:
408-
delattr(THREAD_LOCAL, self.osc._session_id)
409-
409+
delattr(THREAD_LOCAL, generate_session_id(username=self.osc.username, url=self.osc.url))
410410
except AttributeError:
411411
pass
412412

@@ -499,6 +499,7 @@ def test_handle_401(self, *_):
499499

500500
class TestError(TestCase):
501501
url = "http://example.com"
502+
502503
@property
503504
def osc(self) -> Osc:
504505
return Osc(url=self.url, username="nemo", password="password")
@@ -507,9 +508,9 @@ def osc(self) -> Osc:
507508
def test_get_http_error_details(self):
508509
status = 400
509510
summary = "Bla Bla Bla"
510-
responses.add(
511+
rsp_mock = responses.add(
511512
responses.GET,
512-
"http://example.com",
513+
self.url,
513514
body=f"""<status code="foo"><summary>{summary}</summary></status>""",
514515
status=status
515516
)
@@ -528,12 +529,17 @@ def test_get_http_error_details(self):
528529
else:
529530
self.fail("No exception was raised")
530531

532+
with self.subTest("Request count"):
533+
if rsp_mock is not None:
534+
# In the last version of `responses` supporting Python 3.6, `rsp_mock` is None
535+
self.assertEqual(1, rsp_mock.call_count)
536+
531537
@responses.activate
532538
def test_get_http_error_details__bad_response(self):
533539
status = 502
534-
responses.add(
540+
rsp_mock = responses.add(
535541
responses.GET,
536-
"http://example.com",
542+
self.url,
537543
body=f"""Bad Gateway HTML message""",
538544
status=status
539545
)
@@ -558,6 +564,11 @@ def test_get_http_error_details__bad_response(self):
558564
else:
559565
self.fail("No exception was raised")
560566

567+
with self.subTest("Request count"):
568+
if rsp_mock is not None:
569+
# In the last version of `responses` supporting Python 3.6, `rsp_mock` is None
570+
self.assertEqual(self.osc.retry_policy.max_attempts + 1, rsp_mock.call_count)
571+
561572

562573
@mock.patch("osctiny.utils.cookies._conf", new=None)
563574
class TestCookies(TestCase):

‎osctiny/utils/session.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
HTTP Session
3+
^^^^^^^^^^^^
4+
5+
.. versionadded:: {{ NEXT_RELEASE }}
6+
"""
7+
from base64 import b64encode
8+
import os
9+
from ssl import get_default_verify_paths
10+
import threading
11+
import typing
12+
13+
from requests import Session
14+
from requests.adapters import HTTPAdapter
15+
from requests.auth import AuthBase
16+
import urllib3
17+
18+
from .cookies import CookieManager
19+
20+
21+
class RetryPolicy(typing.NamedTuple):
22+
"""
23+
Parameters for governing request retries
24+
"""
25+
max_attempts: int = 6
26+
backoff_factor: float = 0.125
27+
backoff_max: float = 5.
28+
29+
30+
def generate_session_id(username: str, url: str) -> str:
31+
"""
32+
Generate a session ID unique to the user, remote host, process and thread.
33+
"""
34+
session_hash = b64encode(f'{username}@{url}'.encode()).decode()
35+
return f"session_{session_hash}_{os.getpid()}_{threading.get_ident()}"
36+
37+
38+
def generate_retry_policy(policy: RetryPolicy) -> urllib3.Retry:
39+
"""
40+
Generate retry policy for HTTP requests
41+
"""
42+
version = tuple(int(x) for x in urllib3.__version__.split("."))
43+
kwargs = {
44+
"connect": policy.max_attempts,
45+
"read": policy.max_attempts,
46+
"status": policy.max_attempts,
47+
"status_forcelist": [500, 502, 503, 504],
48+
"redirect": False, # Don't limit redirects.
49+
"backoff_factor": policy.backoff_factor,
50+
"raise_on_status": False
51+
}
52+
53+
# Older versions of Retry do not understand some parameters:
54+
if version < (1, 26, 0):
55+
kwargs.update({
56+
"method_whitelist": None,
57+
})
58+
else:
59+
new_kwargs = {
60+
"allowed_methods": None, # Aka. allow all!
61+
"other": 0, # Fail on all other errors.
62+
}
63+
if version >= (2, 0, 0):
64+
new_kwargs["backoff_max"] = policy.backoff_max
65+
kwargs.update(new_kwargs)
66+
67+
return urllib3.Retry(**kwargs)
68+
69+
70+
def init_session(auth: AuthBase, policy: typing.Optional[RetryPolicy] = None,
71+
verify: typing.Union[str, bool] = get_default_verify_paths().capath) -> Session:
72+
"""
73+
Factory to initialize a session object.
74+
"""
75+
session = Session()
76+
session.auth = auth
77+
session.cookies = CookieManager.get_jar()
78+
session.verify = verify
79+
80+
if policy:
81+
retries = generate_retry_policy(policy=policy)
82+
for proto in ('http://', 'https://'):
83+
session.mount(proto, HTTPAdapter(max_retries=retries))
84+
85+
return session

‎requirements.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ requests
33
python-dateutil
44
pytz
55
pyyaml
6+
urllib3

0 commit comments

Comments
 (0)