| OLD | NEW |
| 1 # Copyright 2010 Google Inc. | 1 # Copyright 2010 Google Inc. |
| 2 # | 2 # |
| 3 # Permission is hereby granted, free of charge, to any person obtaining a | 3 # Permission is hereby granted, free of charge, to any person obtaining a |
| 4 # copy of this software and associated documentation files (the | 4 # copy of this software and associated documentation files (the |
| 5 # "Software"), to deal in the Software without restriction, including | 5 # "Software"), to deal in the Software without restriction, including |
| 6 # without limitation the rights to use, copy, modify, merge, publish, dis- | 6 # without limitation the rights to use, copy, modify, merge, publish, dis- |
| 7 # tribute, sublicense, and/or sell copies of the Software, and to permit | 7 # tribute, sublicense, and/or sell copies of the Software, and to permit |
| 8 # persons to whom the Software is furnished to do so, subject to the fol- | 8 # persons to whom the Software is furnished to do so, subject to the fol- |
| 9 # lowing conditions: | 9 # lowing conditions: |
| 10 # | 10 # |
| 11 # The above copyright notice and this permission notice shall be included | 11 # The above copyright notice and this permission notice shall be included |
| 12 # in all copies or substantial portions of the Software. | 12 # in all copies or substantial portions of the Software. |
| 13 # | 13 # |
| 14 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS | 14 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS |
| 15 # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- | 15 # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- |
| 16 # ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT | 16 # ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT |
| 17 # SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | 17 # SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, |
| 18 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | 18 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 19 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS | 19 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS |
| 20 # IN THE SOFTWARE. | 20 # IN THE SOFTWARE. |
| 21 | 21 |
| 22 import cgi | 22 import cgi |
| 23 import errno | 23 import errno |
| 24 import httplib | 24 import httplib |
| 25 import os | 25 import os |
| 26 import random |
| 26 import re | 27 import re |
| 27 import socket | 28 import socket |
| 28 import time | 29 import time |
| 29 import urlparse | 30 import urlparse |
| 30 import boto | 31 import boto |
| 31 from boto import config | 32 from boto import config |
| 32 from boto.connection import AWSAuthConnection | 33 from boto.connection import AWSAuthConnection |
| 33 from boto.exception import InvalidUriError | 34 from boto.exception import InvalidUriError |
| 34 from boto.exception import ResumableTransferDisposition | 35 from boto.exception import ResumableTransferDisposition |
| 35 from boto.exception import ResumableUploadException | 36 from boto.exception import ResumableUploadException |
| 36 | 37 |
| 37 """ | 38 """ |
| 38 Handler for Google Storage resumable uploads. See | 39 Handler for Google Cloud Storage resumable uploads. See |
| 39 http://code.google.com/apis/storage/docs/developer-guide.html#resumable | 40 http://code.google.com/apis/storage/docs/developer-guide.html#resumable |
| 40 for details. | 41 for details. |
| 41 | 42 |
| 42 Resumable uploads will retry failed uploads, resuming at the byte | 43 Resumable uploads will retry failed uploads, resuming at the byte |
| 43 count completed by the last upload attempt. If too many retries happen with | 44 count completed by the last upload attempt. If too many retries happen with |
| 44 no progress (per configurable num_retries param), the upload will be | 45 no progress (per configurable num_retries param), the upload will be |
| 45 aborted in the current process. | 46 aborted in the current process. |
| 46 | 47 |
| 47 The caller can optionally specify a tracker_file_name param in the | 48 The caller can optionally specify a tracker_file_name param in the |
| 48 ResumableUploadHandler constructor. If you do this, that file will | 49 ResumableUploadHandler constructor. If you do this, that file will |
| (...skipping 87 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 136 | 137 |
| 137 def _set_tracker_uri(self, uri): | 138 def _set_tracker_uri(self, uri): |
| 138 """ | 139 """ |
| 139 Called when we start a new resumable upload or get a new tracker | 140 Called when we start a new resumable upload or get a new tracker |
| 140 URI for the upload. Saves URI and resets upload state. | 141 URI for the upload. Saves URI and resets upload state. |
| 141 | 142 |
| 142 Raises InvalidUriError if URI is syntactically invalid. | 143 Raises InvalidUriError if URI is syntactically invalid. |
| 143 """ | 144 """ |
| 144 parse_result = urlparse.urlparse(uri) | 145 parse_result = urlparse.urlparse(uri) |
| 145 if (parse_result.scheme.lower() not in ['http', 'https'] or | 146 if (parse_result.scheme.lower() not in ['http', 'https'] or |
| 146 not parse_result.netloc or not parse_result.query): | 147 not parse_result.netloc): |
| 147 raise InvalidUriError('Invalid tracker URI (%s)' % uri) | |
| 148 qdict = cgi.parse_qs(parse_result.query) | |
| 149 if not qdict or not 'upload_id' in qdict: | |
| 150 raise InvalidUriError('Invalid tracker URI (%s)' % uri) | 148 raise InvalidUriError('Invalid tracker URI (%s)' % uri) |
| 151 self.tracker_uri = uri | 149 self.tracker_uri = uri |
| 152 self.tracker_uri_host = parse_result.netloc | 150 self.tracker_uri_host = parse_result.netloc |
| 153 self.tracker_uri_path = '%s/?%s' % (parse_result.netloc, | 151 self.tracker_uri_path = '%s?%s' % ( |
| 154 parse_result.query) | 152 parse_result.path, parse_result.query) |
| 155 self.server_has_bytes = 0 | 153 self.server_has_bytes = 0 |
| 156 | 154 |
| 157 def get_tracker_uri(self): | 155 def get_tracker_uri(self): |
| 158 """ | 156 """ |
| 159 Returns upload tracker URI, or None if the upload has not yet started. | 157 Returns upload tracker URI, or None if the upload has not yet started. |
| 160 """ | 158 """ |
| 161 return self.tracker_uri | 159 return self.tracker_uri |
| 162 | 160 |
| 163 def _remove_tracker_file(self): | 161 def _remove_tracker_file(self): |
| 164 if (self.tracker_file_name and | 162 if (self.tracker_file_name and |
| 165 os.path.exists(self.tracker_file_name)): | 163 os.path.exists(self.tracker_file_name)): |
| 166 os.unlink(self.tracker_file_name) | 164 os.unlink(self.tracker_file_name) |
| 167 | 165 |
| 168 def _build_content_range_header(self, range_spec='*', length_spec='*'): | 166 def _build_content_range_header(self, range_spec='*', length_spec='*'): |
| 169 return 'bytes %s/%s' % (range_spec, length_spec) | 167 return 'bytes %s/%s' % (range_spec, length_spec) |
| 170 | 168 |
| 171 def _query_server_state(self, conn, file_length): | 169 def _query_server_state(self, conn, file_length): |
| 172 """ | 170 """ |
| 173 Queries server to find out what bytes it currently has. | 171 Queries server to find out state of given upload. |
| 174 | 172 |
| 175 Note that this method really just makes special case use of the | 173 Note that this method really just makes special case use of the |
| 176 fact that the upload server always returns the current start/end | 174 fact that the upload server always returns the current start/end |
| 177 state whenever a PUT doesn't complete. | 175 state whenever a PUT doesn't complete. |
| 178 | 176 |
| 179 Returns (server_start, server_end), where the values are inclusive. | 177 Returns HTTP response from sending request. |
| 180 For example, (0, 2) would mean that the server has bytes 0, 1, *and* 2. | |
| 181 | 178 |
| 182 Raises ResumableUploadException if problem querying server. | 179 Raises ResumableUploadException if problem querying server. |
| 183 """ | 180 """ |
| 184 # Send an empty PUT so that server replies with this resumable | 181 # Send an empty PUT so that server replies with this resumable |
| 185 # transfer's state. | 182 # transfer's state. |
| 186 put_headers = {} | 183 put_headers = {} |
| 187 put_headers['Content-Range'] = ( | 184 put_headers['Content-Range'] = ( |
| 188 self._build_content_range_header('*', file_length)) | 185 self._build_content_range_header('*', file_length)) |
| 189 put_headers['Content-Length'] = '0' | 186 put_headers['Content-Length'] = '0' |
| 190 resp = AWSAuthConnection.make_request(conn, 'PUT', | 187 return AWSAuthConnection.make_request(conn, 'PUT', |
| 191 path=self.tracker_uri_path, | 188 path=self.tracker_uri_path, |
| 192 auth_path=self.tracker_uri_path, | 189 auth_path=self.tracker_uri_path, |
| 193 headers=put_headers, | 190 headers=put_headers, |
| 194 host=self.tracker_uri_host) | 191 host=self.tracker_uri_host) |
| 192 |
| 193 def _query_server_pos(self, conn, file_length): |
| 194 """ |
| 195 Queries server to find out what bytes it currently has. |
| 196 |
| 197 Returns (server_start, server_end), where the values are inclusive. |
| 198 For example, (0, 2) would mean that the server has bytes 0, 1, *and* 2. |
| 199 |
| 200 Raises ResumableUploadException if problem querying server. |
| 201 """ |
| 202 resp = self._query_server_state(conn, file_length) |
| 195 if resp.status == 200: | 203 if resp.status == 200: |
| 196 return (0, file_length) # Completed upload. | 204 return (0, file_length) # Completed upload. |
| 197 if resp.status != 308: | 205 if resp.status != 308: |
| 198 # This means the server didn't have any state for the given | 206 # This means the server didn't have any state for the given |
| 199 # upload ID, which can happen (for example) if the caller saved | 207 # upload ID, which can happen (for example) if the caller saved |
| 200 # the tracker URI to a file and then tried to restart the transfer | 208 # the tracker URI to a file and then tried to restart the transfer |
| 201 # after that upload ID has gone stale. In that case we need to | 209 # after that upload ID has gone stale. In that case we need to |
| 202 # start a new transfer (and the caller will then save the new | 210 # start a new transfer (and the caller will then save the new |
| 203 # tracker URI to the tracker file). | 211 # tracker URI to the tracker file). |
| 204 raise ResumableUploadException( | 212 raise ResumableUploadException( |
| (...skipping 51 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 256 ResumableTransferDisposition.ABORT) | 264 ResumableTransferDisposition.ABORT) |
| 257 post_headers[k] = headers[k] | 265 post_headers[k] = headers[k] |
| 258 post_headers[conn.provider.resumable_upload_header] = 'start' | 266 post_headers[conn.provider.resumable_upload_header] = 'start' |
| 259 | 267 |
| 260 resp = conn.make_request( | 268 resp = conn.make_request( |
| 261 'POST', key.bucket.name, key.name, post_headers) | 269 'POST', key.bucket.name, key.name, post_headers) |
| 262 # Get tracker URI from response 'Location' header. | 270 # Get tracker URI from response 'Location' header. |
| 263 body = resp.read() | 271 body = resp.read() |
| 264 | 272 |
| 265 # Check for various status conditions. | 273 # Check for various status conditions. |
| 266 if resp.status == 500 or resp.status == 503: | 274 if resp.status in [500, 503]: |
| 267 # Retry status 500 and 503 errors after a delay. | 275 # Retry status 500 and 503 errors after a delay. |
| 268 raise ResumableUploadException( | 276 raise ResumableUploadException( |
| 269 'Got status %d from attempt to start resumable upload. ' | 277 'Got status %d from attempt to start resumable upload. ' |
| 270 'Will wait/retry' % resp.status, | 278 'Will wait/retry' % resp.status, |
| 271 ResumableTransferDisposition.WAIT_BEFORE_RETRY) | 279 ResumableTransferDisposition.WAIT_BEFORE_RETRY) |
| 272 elif resp.status != 200 and resp.status != 201: | 280 elif resp.status != 200 and resp.status != 201: |
| 273 raise ResumableUploadException( | 281 raise ResumableUploadException( |
| 274 'Got status %d from attempt to start resumable upload. ' | 282 'Got status %d from attempt to start resumable upload. ' |
| 275 'Aborting' % resp.status, | 283 'Aborting' % resp.status, |
| 276 ResumableTransferDisposition.ABORT) | 284 ResumableTransferDisposition.ABORT) |
| (...skipping 59 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 336 if cb: | 344 if cb: |
| 337 i += 1 | 345 i += 1 |
| 338 if i == cb_count or cb_count == -1: | 346 if i == cb_count or cb_count == -1: |
| 339 cb(total_bytes_uploaded, file_length) | 347 cb(total_bytes_uploaded, file_length) |
| 340 i = 0 | 348 i = 0 |
| 341 buf = fp.read(self.BUFFER_SIZE) | 349 buf = fp.read(self.BUFFER_SIZE) |
| 342 if cb: | 350 if cb: |
| 343 cb(total_bytes_uploaded, file_length) | 351 cb(total_bytes_uploaded, file_length) |
| 344 if total_bytes_uploaded != file_length: | 352 if total_bytes_uploaded != file_length: |
| 345 # Abort (and delete the tracker file) so if the user retries | 353 # Abort (and delete the tracker file) so if the user retries |
| 346 # they'll start a new resumable uplaod rather than potentially | 354 # they'll start a new resumable upload rather than potentially |
| 347 # attempting to pick back up later where we left off. | 355 # attempting to pick back up later where we left off. |
| 348 raise ResumableUploadException( | 356 raise ResumableUploadException( |
| 349 'File changed during upload: EOF at %d bytes of %d byte file.' % | 357 'File changed during upload: EOF at %d bytes of %d byte file.' % |
| 350 (total_bytes_uploaded, file_length), | 358 (total_bytes_uploaded, file_length), |
| 351 ResumableTransferDisposition.ABORT) | 359 ResumableTransferDisposition.ABORT) |
| 352 resp = http_conn.getresponse() | 360 resp = http_conn.getresponse() |
| 353 body = resp.read() | 361 body = resp.read() |
| 354 # Restore http connection debug level. | 362 # Restore http connection debug level. |
| 355 http_conn.set_debuglevel(conn.debug) | 363 http_conn.set_debuglevel(conn.debug) |
| 356 | 364 |
| 357 additional_note = '' | |
| 358 if resp.status == 200: | 365 if resp.status == 200: |
| 359 return resp.getheader('etag') # Success | 366 return resp.getheader('etag') # Success |
| 360 elif resp.status == 408: | 367 # Retry timeout (408) and status 500 and 503 errors after a delay. |
| 361 # Request Timeout. Try again later within the current process. | 368 elif resp.status in [408, 500, 503]: |
| 362 disposition = ResumableTransferDisposition.WAIT_BEFORE_RETRY | |
| 363 elif resp.status/100 == 4: | |
| 364 # Abort for any other 4xx errors. | |
| 365 disposition = ResumableTransferDisposition.ABORT | |
| 366 # Add some more informative note for particular 4xx error codes. | |
| 367 if resp.status == 400: | |
| 368 additional_note = ('This can happen for various reasons; one ' | |
| 369 'common case is if you attempt to upload a ' | |
| 370 'different size file on a already partially ' | |
| 371 'uploaded resumable upload') | |
| 372 # Retry status 500 and 503 errors after a delay. | |
| 373 elif resp.status == 500 or resp.status == 503: | |
| 374 disposition = ResumableTransferDisposition.WAIT_BEFORE_RETRY | 369 disposition = ResumableTransferDisposition.WAIT_BEFORE_RETRY |
| 375 else: | 370 else: |
| 376 # Catch all for any other error codes. | 371 # Catch all for any other error codes. |
| 377 disposition = ResumableTransferDisposition.ABORT | 372 disposition = ResumableTransferDisposition.ABORT |
| 378 raise ResumableUploadException('Got response code %d while attempting ' | 373 raise ResumableUploadException('Got response code %d while attempting ' |
| 379 'upload (%s)%s' % | 374 'upload (%s)' % |
| 380 (resp.status, resp.reason, | 375 (resp.status, resp.reason), disposition) |
| 381 additional_note), disposition) | |
| 382 | 376 |
| 383 def _attempt_resumable_upload(self, key, fp, file_length, headers, cb, | 377 def _attempt_resumable_upload(self, key, fp, file_length, headers, cb, |
| 384 num_cb): | 378 num_cb): |
| 385 """ | 379 """ |
| 386 Attempts a resumable upload. | 380 Attempts a resumable upload. |
| 387 | 381 |
| 388 Returns etag from server upon success. | 382 Returns etag from server upon success. |
| 389 | 383 |
| 390 Raises ResumableUploadException if any problems occur. | 384 Raises ResumableUploadException if any problems occur. |
| 391 """ | 385 """ |
| 392 (server_start, server_end) = self.SERVER_HAS_NOTHING | 386 (server_start, server_end) = self.SERVER_HAS_NOTHING |
| 393 conn = key.bucket.connection | 387 conn = key.bucket.connection |
| 394 if self.tracker_uri: | 388 if self.tracker_uri: |
| 395 # Try to resume existing resumable upload. | 389 # Try to resume existing resumable upload. |
| 396 try: | 390 try: |
| 397 (server_start, server_end) = ( | 391 (server_start, server_end) = ( |
| 398 self._query_server_state(conn, file_length)) | 392 self._query_server_pos(conn, file_length)) |
| 399 self.server_has_bytes = server_start | 393 self.server_has_bytes = server_start |
| 400 key=key | 394 key=key |
| 401 if conn.debug >= 1: | 395 if conn.debug >= 1: |
| 402 print 'Resuming transfer.' | 396 print 'Resuming transfer.' |
| 403 except ResumableUploadException, e: | 397 except ResumableUploadException, e: |
| 404 if conn.debug >= 1: | 398 if conn.debug >= 1: |
| 405 print 'Unable to resume transfer (%s).' % e.message | 399 print 'Unable to resume transfer (%s).' % e.message |
| 406 self._start_new_resumable_upload(key, headers) | 400 self._start_new_resumable_upload(key, headers) |
| 407 else: | 401 else: |
| 408 self._start_new_resumable_upload(key, headers) | 402 self._start_new_resumable_upload(key, headers) |
| (...skipping 25 matching lines...) Expand all Loading... |
| 434 http_conn = conn.new_http_connection(self.tracker_uri_host, | 428 http_conn = conn.new_http_connection(self.tracker_uri_host, |
| 435 conn.is_secure) | 429 conn.is_secure) |
| 436 http_conn.set_debuglevel(conn.debug) | 430 http_conn.set_debuglevel(conn.debug) |
| 437 | 431 |
| 438 # Make sure to close http_conn at end so if a local file read | 432 # Make sure to close http_conn at end so if a local file read |
| 439 # failure occurs partway through server will terminate current upload | 433 # failure occurs partway through server will terminate current upload |
| 440 # and can report that progress on next attempt. | 434 # and can report that progress on next attempt. |
| 441 try: | 435 try: |
| 442 return self._upload_file_bytes(conn, http_conn, fp, file_length, | 436 return self._upload_file_bytes(conn, http_conn, fp, file_length, |
| 443 total_bytes_uploaded, cb, num_cb) | 437 total_bytes_uploaded, cb, num_cb) |
| 438 except (ResumableUploadException, socket.error): |
| 439 resp = self._query_server_state(conn, file_length) |
| 440 if resp.status == 400: |
| 441 raise ResumableUploadException('Got 400 response from server ' |
| 442 'state query after failed resumable upload attempt. This ' |
| 443 'can happen for various reasons, including specifying an ' |
| 444 'invalid request (e.g., an invalid canned ACL) or if the ' |
| 445 'file size changed between upload attempts', |
| 446 ResumableTransferDisposition.ABORT) |
| 447 else: |
| 448 raise |
| 444 finally: | 449 finally: |
| 445 http_conn.close() | 450 http_conn.close() |
| 446 | 451 |
| 447 def _check_final_md5(self, key, etag): | 452 def _check_final_md5(self, key, etag): |
| 448 """ | 453 """ |
| 449 Checks that etag from server agrees with md5 computed before upload. | 454 Checks that etag from server agrees with md5 computed before upload. |
| 450 This is important, since the upload could have spanned a number of | 455 This is important, since the upload could have spanned a number of |
| 451 hours and multiple processes (e.g., gsutil runs), and the user could | 456 hours and multiple processes (e.g., gsutil runs), and the user could |
| 452 change some of the file and not realize they have inconsistent data. | 457 change some of the file and not realize they have inconsistent data. |
| 453 """ | 458 """ |
| (...skipping 39 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 493 parameter, this parameter determines the granularity of the callback | 498 parameter, this parameter determines the granularity of the callback |
| 494 by defining the maximum number of times the callback will be called | 499 by defining the maximum number of times the callback will be called |
| 495 during the file transfer. Providing a negative integer will cause | 500 during the file transfer. Providing a negative integer will cause |
| 496 your callback to be called with each buffer read. | 501 your callback to be called with each buffer read. |
| 497 | 502 |
| 498 Raises ResumableUploadException if a problem occurs during the transfer. | 503 Raises ResumableUploadException if a problem occurs during the transfer. |
| 499 """ | 504 """ |
| 500 | 505 |
| 501 if not headers: | 506 if not headers: |
| 502 headers = {} | 507 headers = {} |
| 508 # If Content-Type header is present and set to None, remove it. |
| 509 # This is gsutil's way of asking boto to refrain from auto-generating |
| 510 # that header. |
| 511 CT = 'Content-Type' |
| 512 if CT in headers and headers[CT] is None: |
| 513 del headers[CT] |
| 503 | 514 |
| 504 fp.seek(0, os.SEEK_END) | 515 fp.seek(0, os.SEEK_END) |
| 505 file_length = fp.tell() | 516 file_length = fp.tell() |
| 506 fp.seek(0) | 517 fp.seek(0) |
| 507 debug = key.bucket.connection.debug | 518 debug = key.bucket.connection.debug |
| 508 | 519 |
| 509 # Use num-retries from constructor if one was provided; else check | 520 # Use num-retries from constructor if one was provided; else check |
| 510 # for a value specified in the boto config file; else default to 5. | 521 # for a value specified in the boto config file; else default to 5. |
| 511 if self.num_retries is None: | 522 if self.num_retries is None: |
| 512 self.num_retries = config.getint('Boto', 'num_retries', 5) | 523 self.num_retries = config.getint('Boto', 'num_retries', 5) |
| (...skipping 47 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 560 else: | 571 else: |
| 561 progress_less_iterations += 1 | 572 progress_less_iterations += 1 |
| 562 | 573 |
| 563 if progress_less_iterations > self.num_retries: | 574 if progress_less_iterations > self.num_retries: |
| 564 # Don't retry any longer in the current process. | 575 # Don't retry any longer in the current process. |
| 565 raise ResumableUploadException( | 576 raise ResumableUploadException( |
| 566 'Too many resumable upload attempts failed without ' | 577 'Too many resumable upload attempts failed without ' |
| 567 'progress. You might try this upload again later', | 578 'progress. You might try this upload again later', |
| 568 ResumableTransferDisposition.ABORT_CUR_PROCESS) | 579 ResumableTransferDisposition.ABORT_CUR_PROCESS) |
| 569 | 580 |
| 570 sleep_time_secs = 2**progress_less_iterations | 581 # Use binary exponential backoff to desynchronize client requests |
| 582 sleep_time_secs = random.random() * (2**progress_less_iterations) |
| 571 if debug >= 1: | 583 if debug >= 1: |
| 572 print ('Got retryable failure (%d progress-less in a row).\n' | 584 print ('Got retryable failure (%d progress-less in a row).\n' |
| 573 'Sleeping %d seconds before re-trying' % | 585 'Sleeping %3.1f seconds before re-trying' % |
| 574 (progress_less_iterations, sleep_time_secs)) | 586 (progress_less_iterations, sleep_time_secs)) |
| 575 time.sleep(sleep_time_secs) | 587 time.sleep(sleep_time_secs) |
| OLD | NEW |