-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Raise exception rather than swallowing it if there is something wrong… #16783
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
5665ce9
7ab97d5
9193656
b40285c
a7ac8aa
213edfb
95a867a
00e5f77
83d4219
0ede2c9
aff402b
35256ac
c73f6a6
526fca4
76a4186
3d94d8d
5c97a13
21f30ad
cd8acc6
ba3fde3
982865c
5d93063
a61c9de
207a613
f201640
5acd6b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -27,6 +27,7 @@ | |||
| import logging | ||||
| from typing import Iterator, Optional, Any, Union, TypeVar | ||||
| import time | ||||
| import six | ||||
| import urllib3 # type: ignore | ||||
| from urllib3.util.retry import Retry # type: ignore | ||||
| import requests | ||||
|
|
@@ -49,6 +50,27 @@ | |||
|
|
||||
| _LOGGER = logging.getLogger(__name__) | ||||
|
|
||||
| def parse_range_header(header_value): | ||||
| range_value = header_value.strip() | ||||
| if not range_value.startswith("bytes="): | ||||
| raise ValueError("Invalid header") | ||||
| range = range_value[6:] | ||||
| ret = range.split("-") | ||||
| if len(ret) < 2: | ||||
| raise ValueError("Invalid header") | ||||
| start = int(ret[0]) if ret[0] else -1 | ||||
| end = int(ret[1]) if ret[1] else -1 | ||||
| return (start, end) | ||||
|
|
||||
| def make_range_header(original_range, downloaded_size=0): | ||||
| if original_range[0] == -1: | ||||
| end = original_range[1] - downloaded_size | ||||
| return "bytes=-" + str(end) | ||||
| start = original_range[0] + downloaded_size | ||||
| if original_range[1] == -1: | ||||
| return "bytes=" + str(start) + "-" | ||||
| return "bytes=" + str(start) + "-" + str(original_range[1]) | ||||
|
|
||||
|
|
||||
| class _RequestsTransportResponseBase(_HttpResponseBase): | ||||
| """Base class for accessing response data. | ||||
|
|
@@ -108,6 +130,19 @@ def __init__(self, pipeline, response): | |||
| self.iter_content_func = self.response.internal_response.iter_content(self.block_size) | ||||
| self.content_length = int(response.headers.get('Content-Length', 0)) | ||||
| self.downloaded = 0 | ||||
| headers = response.request.headers | ||||
| if "x-ms-range" in headers: | ||||
| self.range_header = "x-ms-range" | ||||
| self.range = headers["x-ms-range"] | ||||
|
xiangyan99 marked this conversation as resolved.
Outdated
|
||||
| elif "Range" in headers: | ||||
| self.range_header = "Range" | ||||
| self.range = headers["Range"] | ||||
| else: | ||||
| self.range_header = None | ||||
| if 'etag' in response.headers: | ||||
| self.etag = response.headers['etag'] | ||||
| else: | ||||
| self.etag = None | ||||
|
|
||||
| def __len__(self): | ||||
| return self.content_length | ||||
|
|
@@ -119,39 +154,58 @@ def __next__(self): | |||
| retry_active = True | ||||
| retry_total = 3 | ||||
| retry_interval = 1 # 1 second | ||||
| while retry_active: | ||||
| try: | ||||
| chunk = next(self.iter_content_func) | ||||
| if not chunk: | ||||
| raise StopIteration() | ||||
| self.downloaded += self.block_size | ||||
| return chunk | ||||
| except StopIteration: | ||||
| self.response.internal_response.close() | ||||
| try: | ||||
| chunk = next(self.iter_content_func) | ||||
| if not chunk: | ||||
| raise StopIteration() | ||||
| except (requests.exceptions.ChunkedEncodingError, | ||||
| requests.exceptions.ConnectionError): | ||||
| self.downloaded += self.block_size | ||||
| return chunk | ||||
| except StopIteration: | ||||
| self.response.internal_response.close() | ||||
| raise StopIteration() | ||||
| except (requests.exceptions.ChunkedEncodingError, | ||||
| requests.exceptions.ConnectionError) as ex: | ||||
| while retry_active: | ||||
| retry_total -= 1 | ||||
| if retry_total <= 0: | ||||
| retry_active = False | ||||
| _LOGGER.warning("Unable to stream download: %s", ex) | ||||
| raise ex | ||||
| else: | ||||
| time.sleep(retry_interval) | ||||
| headers = {'range': 'bytes=' + str(self.downloaded) + '-'} | ||||
| resp = self.pipeline.run(self.request, stream=True, headers=headers) | ||||
| if resp.http_response.status_code == 416: | ||||
| raise | ||||
| chunk = next(self.iter_content_func) | ||||
| # todo handle pre-set range & x-ms-range | ||||
| headers = self.request.headers | ||||
| if not self.range_header: | ||||
| range_header = {'range': 'bytes=' + str(self.downloaded) + '-'} | ||||
| else: | ||||
| range_header = {self.range_header: make_range_header(self.range, self.downloaded)} | ||||
| if self.etag: | ||||
|
xiangyan99 marked this conversation as resolved.
Outdated
|
||||
| range_header.update({'If-Match': self.etag}) | ||||
| headers.update(range_header) | ||||
|
xiangyan99 marked this conversation as resolved.
Outdated
|
||||
| try: | ||||
| resp = self.pipeline.run(self.request, stream=True, headers=headers) | ||||
| if not resp.http_response: | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't recall that the HttpResponse object had a boolean value? Or will this value be None under some circumstances?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||
| continue | ||||
| if resp.http_response.status_code == 412: | ||||
| raise ex | ||||
| self.response = resp | ||||
| self.iter_content_func = resp.http_response.iter_content(self.block_size) | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This last change of re-setting
So this can produce complete garbage data! The only way out I can see here is to remove the retry entirely. This function just does not have enough context information to re-try safely. So the best it can do is to raise and let the upper layers handle it.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you. Yes. I was about to answer this. :) As you know, the purpose for azure-core is to support other SDK libraries. We have not heard any real scenarios from libraries that need to read bytes like "bytes=1000-1999". In storage, we always download entire file. This made us leaning towards having retry rather than failing directly. But yes, you are right. It is not correct in case "bytes=1000-1999". I will open an issue to track it. In the meantime, do you have real scenarios that need this? That can help us prioritize the issue. Thanks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Surely you are aware this is used in the azure-storage-blob library, which does set range headers by default, as it will use multi-threading to download large blobs in chunks, in parallel? See e.g. here: azure-sdk-for-python/sdk/storage/azure-storage-blob/azure/storage/blob/_download.py Line 505 in b222aa7
Yes, we have real scenarios we use this. As you might be aware, we use azure storage blob for big data analysis, processing dozens of TB daily. We had several customer-facing incidents that are very likely due to this bug. We spent person-weeks tracking this down and working around this. |
||||
| chunk = next(self.iter_content_func) | ||||
|
xiangyan99 marked this conversation as resolved.
Outdated
|
||||
| self.downloaded += self.block_size | ||||
| return chunk | ||||
| except StopIteration: | ||||
| self.response.internal_response.close() | ||||
| raise StopIteration() | ||||
| except Exception: # pylint: disable=broad-except | ||||
| continue | ||||
| if not chunk: | ||||
| self.response.internal_response.close() | ||||
| raise StopIteration() | ||||
| self.downloaded += len(chunk) | ||||
| return chunk | ||||
| continue | ||||
| except requests.exceptions.StreamConsumedError: | ||||
| raise | ||||
| except Exception as err: | ||||
| _LOGGER.warning("Unable to stream download: %s", err) | ||||
| self.response.internal_response.close() | ||||
| raise | ||||
| except requests.exceptions.StreamConsumedError: | ||||
| raise | ||||
| except Exception as err: | ||||
| _LOGGER.warning("Unable to stream download: %s", err) | ||||
| self.response.internal_response.close() | ||||
| raise | ||||
| next = __next__ # Python 2 compatibility. | ||||
|
|
||||
|
|
||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # -------------------------------------------------------------------------- | ||
| # | ||
| # Copyright (c) Microsoft Corporation. All rights reserved. | ||
| # | ||
| # The MIT License (MIT) | ||
| # | ||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| # of this software and associated documentation files (the ""Software""), to deal | ||
| # in the Software without restriction, including without limitation the rights | ||
| # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| # copies of the Software, and to permit persons to whom the Software is | ||
| # furnished to do so, subject to the following conditions: | ||
| # | ||
| # The above copyright notice and this permission notice shall be included in | ||
| # all copies or substantial portions of the Software. | ||
| # | ||
| # THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | ||
| # THE SOFTWARE. | ||
| # | ||
| # -------------------------------------------------------------------------- | ||
|
|
||
| from azure.core.pipeline.transport._requests_basic import parse_range_header, make_range_header | ||
|
|
||
| def test_basic_range_header(): | ||
| range_header = "bytes=0-500" | ||
| range = parse_range_header(range_header) | ||
| assert range == (0,500) | ||
|
|
||
| def test_no_start_range_header(): | ||
| range_header = "bytes=-500" | ||
| range = parse_range_header(range_header) | ||
| assert range == (-1,500) | ||
|
|
||
| def test_no_end_range_header(): | ||
| range_header = "bytes=0-" | ||
| range = parse_range_header(range_header) | ||
| assert range == (0,-1) | ||
|
|
||
| def test_make_basic_range_header(): | ||
| range_header = "bytes=0-500" | ||
| range = parse_range_header(range_header) | ||
| header = make_range_header(range, 100) | ||
| assert header == "bytes=100-500" | ||
|
|
||
| def test_make_no_start_range_header(): | ||
| range_header = "bytes=-500" | ||
| range = parse_range_header(range_header) | ||
| header = make_range_header(range, 100) | ||
| assert header == "bytes=-400" | ||
|
|
||
| def test_make_no_end_range_header(): | ||
| range_header = "bytes=0-" | ||
| range = parse_range_header(range_header) | ||
| header = make_range_header(range, 100) | ||
| assert header == "bytes=100-" |
Uh oh!
There was an error while loading. Please reload this page.