1+ import io
12import logging
23import urllib .parse
34from datetime import timedelta
@@ -130,6 +131,14 @@ def flatten_dict(d: Dict[str, Any]) -> Dict[str, Any]:
130131 flattened = dict (flatten_dict (with_fixed_bools ))
131132 return flattened
132133
134+ @staticmethod
135+ def _is_seekable_stream (data ) -> bool :
136+ if data is None :
137+ return False
138+ if not isinstance (data , io .IOBase ):
139+ return False
140+ return data .seekable ()
141+
133142 def do (self ,
134143 method : str ,
135144 url : str ,
@@ -144,18 +153,25 @@ def do(self,
144153 if headers is None :
145154 headers = {}
146155 headers ['User-Agent' ] = self ._user_agent_base
147- retryable = retried (timeout = timedelta (seconds = self ._retry_timeout_seconds ),
148- is_retryable = self ._is_retryable ,
149- clock = self ._clock )
150- response = retryable (self ._perform )(method ,
151- url ,
152- query = query ,
153- headers = headers ,
154- body = body ,
155- raw = raw ,
156- files = files ,
157- data = data ,
158- auth = auth )
156+
157+ # Only retry if the request is not a stream or if the stream is seekable and
158+ # we can rewind it. This is necessary to avoid bugs where the retry doesn't
159+ # re-read already read data from the body.
160+ call = self ._perform
161+ if data is None or self ._is_seekable_stream (data ):
162+ call = retried (timeout = timedelta (seconds = self ._retry_timeout_seconds ),
163+ is_retryable = self ._is_retryable ,
164+ clock = self ._clock )(call )
165+
166+ response = call (method ,
167+ url ,
168+ query = query ,
169+ headers = headers ,
170+ body = body ,
171+ raw = raw ,
172+ files = files ,
173+ data = data ,
174+ auth = auth )
159175
160176 resp = dict ()
161177 for header in response_headers if response_headers else []:
@@ -226,6 +242,12 @@ def _perform(self,
226242 files = None ,
227243 data = None ,
228244 auth : Callable [[requests .PreparedRequest ], requests .PreparedRequest ] = None ):
245+ # Keep track of the initial position of the stream so that we can rewind it if
246+ # we need to retry the request.
247+ initial_data_position = 0
248+ if self ._is_seekable_stream (data ):
249+ initial_data_position = data .tell ()
250+
229251 response = self ._session .request (method ,
230252 url ,
231253 params = self ._fix_query_string (query ),
@@ -237,9 +259,15 @@ def _perform(self,
237259 stream = raw ,
238260 timeout = self ._http_timeout_seconds )
239261 self ._record_request_log (response , raw = raw or data is not None or files is not None )
262+
240263 error = self ._error_parser .get_api_error (response )
241264 if error is not None :
265+ # If the request body is a seekable stream, rewind it so that it is ready
266+ # to be read again in case of a retry.
267+ if self ._is_seekable_stream (data ):
268+ data .seek (initial_data_position )
242269 raise error from None
270+
243271 return response
244272
245273 def _record_request_log (self , response : requests .Response , raw : bool = False ) -> None :
0 commit comments