11import logging
2+ from contextlib import contextmanager
23
34from requests import exceptions
4- from morango import __version__
5+ from requests . adapters import HTTPAdapter
56from requests .sessions import Session
67from requests .utils import super_len
7- from requests .packages .urllib3 .util .url import parse_url
8+ from urllib3 .exceptions import MaxRetryError
9+ from urllib3 .util .url import parse_url
810
11+ from morango import __version__
12+ from morango .utils import nullcontext
913from morango .utils import serialize_capabilities_to_client_request
1014from morango .utils import SETTINGS
1115
1216
1317logger = logging .getLogger (__name__ )
1418
1519
20+ _RETRY_REQUEST_EXCEPTIONS = (
21+ exceptions .ConnectionError ,
22+ exceptions .ChunkedEncodingError ,
23+ exceptions .ContentDecodingError ,
24+ )
25+
26+
1627def _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,280 @@ def _headers_content_length(headers):
2443
2544
2645def _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+
32109class 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
98260
99- return prepped
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
272+
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 retries .total == 0
297+ or not isinstance (e , _RETRY_REQUEST_EXCEPTIONS )
298+ or not _is_retryable_method (retries , request .method )
299+ ):
300+ return None
301+
302+ # requests might wrap `MaxRetryError` in a `ConnectionError`
303+ if any (isinstance (arg , MaxRetryError ) for arg in e .args ):
304+ return None
305+
306+ try :
307+ # may raise if retries have been exhausted
308+ retries = retries .increment (
309+ method = request .method ,
310+ url = request .url ,
311+ response = response ,
312+ error = e
313+ )
314+ logger .debug (f"Sleeping before retrying { request .method } request to { request .url } " )
315+ retries .sleep (response )
316+ return retries
317+ except MaxRetryError as _e :
318+ # re-raise wrapped exception, like requests would. see `HTTPAdapter.send`
319+ raise exceptions .RetryError (_e .reason , request = request , response = response )
100320
101321 def reset_transfer_bytes (self ):
102322 """
0 commit comments