Skip to content

Commit bab499e

Browse files
committed
Add retry behavior for low level connection issues not captured by urllib3's retries
1 parent 0fd9fa3 commit bab499e

6 files changed

Lines changed: 362 additions & 69 deletions

File tree

morango/sync/controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ def _invoke_middleware(self, context, middleware):
270270
return context.stage_status
271271
except Exception as e:
272272
# always log the error itself
273-
logger.error(e)
273+
logger.exception(e)
274274
context.update(stage_status=transfer_statuses.ERRORED, error=e)
275275
# fire completed signal, after context update. handlers can use context to detect error
276276
signal.completed.fire(context=prepared_context or context)

morango/sync/session.py

Lines changed: 250 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
import logging
2+
from contextlib import contextmanager
23

34
from requests import exceptions
5+
from requests.adapters import HTTPAdapter
46
from requests.sessions import Session
57
from requests.utils import super_len
8+
from urllib3.exceptions import MaxRetryError
69
from urllib3.util.url import parse_url
710

811
from morango import __version__
12+
from morango.utils import nullcontext
913
from morango.utils import serialize_capabilities_to_client_request
1014
from morango.utils import SETTINGS
1115

1216

1317
logger = logging.getLogger(__name__)
1418

1519

20+
_RETRY_REQUEST_EXCEPTIONS = (
21+
exceptions.ConnectionError,
22+
exceptions.ChunkedEncodingError,
23+
exceptions.ContentDecodingError,
24+
)
25+
26+
1627
def _headers_content_length(headers):
28+
"""
29+
Obtains the value of 'Content-Length' from the provided headers.
30+
31+
:param headers: Dictionary of headers
32+
:type headers: dict
33+
:return: The integer value of 'Content-Length' if found and valid, otherwise 0.
34+
:rtype: int
35+
"""
1736
try:
1837
content_length = int(headers.get("Content-Length", 0))
1938
if content_length > 0:
@@ -24,79 +43,275 @@ def _headers_content_length(headers):
2443

2544

2645
def _length_of_headers(headers):
46+
"""
47+
Calculates the total length of all headers.
48+
49+
:param headers: Dictionary of headers
50+
:type headers: dict
51+
:return: The total length of the string representation of all headers.
52+
:rtype: int
53+
"""
2754
return super_len(
2855
"\n".join(["{}: {}".format(key, value) for key, value in headers.items()])
2956
)
3057

3158

59+
def _is_retryable_method(retries, method):
60+
"""
61+
Checks if the request method is configured as retryable.
62+
63+
:type retries: urllib3.util.retry.Retry
64+
:type method: str|None
65+
:rtype: bool
66+
"""
67+
allowed_methods = getattr(retries, "allowed_methods", None)
68+
if allowed_methods is False or allowed_methods is None:
69+
return True
70+
return method.upper() in allowed_methods if method is not None else False
71+
72+
73+
def _log_response_error(err, response):
74+
"""
75+
Logs an error and its associated response content.
76+
77+
:param err: The exception instance that represents the error encountered.
78+
:type err: Exception
79+
:param response: The HTTP response object associated with the error.
80+
If None, it is interpreted as no response being available.
81+
:type response: Optional[Response]
82+
"""
83+
try:
84+
response_content = response.content if response else "(no response)"
85+
except Exception:
86+
response_content = "(unable to read response)"
87+
logger.error(
88+
"{} Reason: {}".format(err.__class__.__name__, response_content)
89+
)
90+
91+
92+
class ContextualRetryHTTPAdapter(HTTPAdapter):
93+
@contextmanager
94+
def use_retries(self, max_retries):
95+
"""
96+
Context manager for temporarily changing the retry configuration.
97+
98+
:param max_retries: The temporary Retry object
99+
:type max_retries: urllib3.util.retry.Retry
100+
"""
101+
original_retries = self.max_retries
102+
try:
103+
self.max_retries = max_retries
104+
yield
105+
finally:
106+
self.max_retries = original_retries
107+
108+
32109
class SessionWrapper(Session):
33110
"""
34111
Wrapper around `requests.sessions.Session` in order to implement logging around all request errors.
35112
"""
36113

37-
bytes_sent = 0
38-
bytes_received = 0
39-
40-
def __init__(self):
114+
def __init__(self, max_retries):
115+
"""
116+
:param max_retries: The urllib3 Retry object
117+
:type max_retries: urllib3.util.retry.Retry
118+
"""
41119
super(SessionWrapper, self).__init__()
120+
self.max_retries = max_retries
121+
42122
user_agent_header = "morango/{}".format(__version__)
43123
if SETTINGS.CUSTOM_INSTANCE_INFO is not None:
44124
instances = list(SETTINGS.CUSTOM_INSTANCE_INFO)
45125
if instances:
46126
user_agent_header += " " + "{}/{}".format(instances[0], SETTINGS.CUSTOM_INSTANCE_INFO.get(instances[0]))
47127
self.headers["User-Agent"] = "{} {}".format(user_agent_header, self.headers["User-Agent"])
128+
self.hooks["response"].append(self._track_bytes_received)
129+
self.bytes_sent = 0
130+
self.bytes_received = 0
48131

49-
def request(self, method, url, **kwargs):
50-
response = None
132+
# use custom adapter
133+
adapter = ContextualRetryHTTPAdapter()
134+
self.mount("http://", adapter)
135+
self.mount("https://", adapter)
136+
137+
def _track_bytes_sent(self, request):
138+
"""
139+
Request hook that tracks the size of the request, by capturing the size of headers and the
140+
request body. Note: python requests only supports the `response` hook, so this is invoked
141+
manually
142+
143+
:type request: requests.Request|requests.PreparedRequest
144+
"""
51145
try:
52-
response = super(SessionWrapper, self).request(method, url, **kwargs)
146+
parsed_url = parse_url(request.url)
147+
# we don't bother checking if the content length header exists here because we've probably
148+
# been given the request body as Morango sends bodies that aren't streamed, so the
149+
# underlying requests code will set it appropriately
150+
self.bytes_sent += len("{} {} HTTP/1.1".format(request.method, parsed_url.path))
151+
self.bytes_sent += _length_of_headers(request.headers)
152+
self.bytes_sent += _headers_content_length(request.headers)
153+
except Exception as e:
154+
# tracking bandwidth usage is useful but not critical
155+
logger.exception(e)
53156

54-
# capture bytes received from the response, the length header could be missing if it's
55-
# a chunked response though
56-
content_length = _headers_content_length(response.headers)
57-
if not content_length:
58-
content_length = super_len(response.content)
157+
def _track_bytes_received(self, response, *args, **kwargs):
158+
"""
159+
Response hook that tracks the size of the response, by capturing the size of headers and
160+
the response body
59161
162+
:type response: requests.Response
163+
"""
164+
try:
165+
# headers:
60166
self.bytes_received += len(
61167
"HTTP/1.1 {} {}".format(response.status_code, response.reason)
62168
)
63169
self.bytes_received += _length_of_headers(response.headers)
64-
self.bytes_received += content_length
65170

66-
response.raise_for_status()
67-
return response
68-
except exceptions.RequestException as req_err:
69-
# we want to log all request errors for debugging purposes
70-
if response is None:
71-
response = req_err.response
171+
# body:
172+
# capture bytes received from the response, the length header could be missing if it's
173+
# a chunked response though
174+
content_length = _headers_content_length(response.headers)
175+
if not content_length:
176+
content_length = super_len(response.content)
177+
self.bytes_received += content_length
178+
except Exception as e:
179+
# tracking bandwidth usage is useful but not critical
180+
logger.exception(e)
72181

73-
response_content = response.content if response else "(no response)"
74-
logger.error(
75-
"{} Reason: {}".format(req_err.__class__.__name__, response_content)
76-
)
77-
raise req_err
182+
def _get_adapter(self, url):
183+
"""
184+
:param url: the request URL
185+
:type url: bytes|str|None
186+
:rtype: Optional[HTTPAdapter]
187+
"""
188+
if url is None:
189+
return None
190+
# requests allows bytes
191+
if isinstance(url, bytes):
192+
url = url.decode("utf-8")
193+
try:
194+
return self.get_adapter(url)
195+
except exceptions.InvalidSchema:
196+
return None
78197

79198
def prepare_request(self, request):
80199
"""
81-
Override request preparer so we can get the prepared content length, for tracking
82-
transfer sizes
200+
Override request preparer so we can add morango capabilities to the request, and invoke
201+
the sent bytes hook.
83202
84203
:type request: requests.Request
85204
:rtype: requests.PreparedRequest
86205
"""
87206
# add header with client's morango capabilities so server has that information
88207
serialize_capabilities_to_client_request(request)
89-
prepped = super(SessionWrapper, self).prepare_request(request)
90-
parsed_url = parse_url(request.url)
208+
return super(SessionWrapper, self).prepare_request(request)
91209

92-
# we don't bother checking if the content length header exists here because we've probably
93-
# been given the request body as Morango sends bodies that aren't streamed, so the
94-
# underlying requests code will set it appropriately
95-
self.bytes_sent += len("{} {} HTTP/1.1".format(request.method, parsed_url.path))
96-
self.bytes_sent += _length_of_headers(prepped.headers)
97-
self.bytes_sent += _headers_content_length(prepped.headers)
210+
def request(self, method, url, **kwargs):
211+
"""
212+
Issues an HTTP request, with conditional retry behavior if passed `is_retryable` kwarg, and
213+
logs any errors from the request flow.
214+
215+
:param method: The HTTP request method (e.g., 'GET', 'POST', 'PUT', etc.).
216+
:type method: str
217+
:param url: The URL to send the request to.
218+
:type url: str
219+
:param kwargs: Additional arguments to pass to the underlying request method.
220+
:return: The HTTP response object obtained from the request.
221+
:rtype: requests.Response
222+
:raises Exception: Logs then re-raises any exception that occurs during the request.
223+
"""
224+
adapter = self._get_adapter(url)
225+
226+
# super's request has strict kwarg list, so we have to pop `is_retryable` and modify
227+
# the adapter state based on the value
228+
is_retryable = kwargs.pop("is_retryable", False)
229+
if is_retryable and isinstance(adapter, ContextualRetryHTTPAdapter):
230+
ctx = adapter.use_retries(self.max_retries)
231+
else:
232+
ctx = nullcontext()
233+
234+
with ctx:
235+
response = None
236+
try:
237+
response = super(SessionWrapper, self).request(method, url, **kwargs)
238+
response.raise_for_status()
239+
return response
240+
except Exception as e:
241+
if response is None:
242+
response = getattr(e, 'response', None)
243+
244+
_log_response_error(e, response)
245+
raise e
246+
247+
def send(self, request, **kwargs):
248+
"""
249+
Issues an HTTP request with automatic retry handling for transport-level failures and
250+
logging of request-related errors.
251+
252+
:param request: The prepared request
253+
:type request: requests.PreparedRequest
254+
:param kwargs: Additional arguments to pass to the underlying `send` method.
255+
:return: The HTTP response object obtained from the request.
256+
:rtype: requests.Response
257+
"""
258+
adapter = self._get_adapter(request.url)
259+
retries = adapter.max_retries if adapter is not None else None
260+
261+
while True:
262+
# sent bytes from low-level retries in urllib3 are not captured, but this is good enough
263+
self._track_bytes_sent(request)
264+
response = None
265+
try:
266+
response = super(SessionWrapper, self).send(request, **kwargs)
267+
return response
268+
except exceptions.RequestException as e:
269+
retries = self._should_retry(retries, request, e, response=response)
270+
if retries is None:
271+
raise e
98272

99-
return prepped
273+
def _should_retry(self, retries, request, e, response=None):
274+
"""
275+
Determines whether a request should be retried based on the provided criteria, including
276+
the retry settings, the type of exception occurred, and the HTTP method used. This method
277+
also handles sleeping between retries and increments retry counters appropriately. If
278+
retries are exhausted, it raises a `RetryError`.
279+
280+
:param retries: The retry configuration instance that tracks and manages retry attempts.
281+
:type retries: Optional[urllib3.util.retry.Retry]
282+
:param request: The HTTP request object that specifies the details of the request being made.
283+
:type request: requests.PreparedRequest
284+
:param e: The exception raised during the request execution that triggered this check.
285+
:type e: requests.exceptions.RequestException
286+
:param response: Optional. The HTTP response object corresponding to the request, if available.
287+
:type response: Optional[requests.Response]
288+
:return: The new Retry object if the retry is allowed, otherwise None.
289+
:rtype: Optional[urllib3.util.retry.Retry]
290+
"""
291+
if response is None:
292+
response = e.response
293+
294+
if (
295+
retries is None
296+
or not isinstance(e, _RETRY_REQUEST_EXCEPTIONS)
297+
or not _is_retryable_method(retries, request.method)
298+
):
299+
return None
300+
301+
try:
302+
# may raise if retries have been exhausted
303+
retries = retries.increment(
304+
method=request.method,
305+
url=request.url,
306+
response=response,
307+
error=e
308+
)
309+
logger.debug(f"Sleeping before retrying {request.method} request to {request.url}")
310+
retries.sleep(response)
311+
return retries
312+
except MaxRetryError as _e:
313+
# re-raise wrapped exception, like requests would. see `HTTPAdapter.send`
314+
raise exceptions.RetryError(_e.reason, request=request, response=response)
100315

101316
def reset_transfer_bytes(self):
102317
"""

0 commit comments

Comments
 (0)