Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions sdk/core/azure-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 1.11.1 (Unreleased)

### Bug Fixes

- Raise exception rather than swallowing it if there is something wrong in retry stream downloading #16723

## 1.11.0 (2021-02-08)

Expand Down
17 changes: 12 additions & 5 deletions sdk/core/azure-core/azure/core/pipeline/transport/_aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,17 +228,24 @@ async def __anext__(self):
except _ResponseStopIteration:
self.response.internal_response.close()
raise StopAsyncIteration()
except (ChunkedEncodingError, ConnectionError):
except (ChunkedEncodingError, ConnectionError) as ex:
retry_total -= 1
if retry_total <= 0:
retry_active = False
else:
await asyncio.sleep(retry_interval)
headers = {'range': 'bytes=' + str(self.downloaded) + '-'}
resp = await self.pipeline.run(self.request, stream=True, headers=headers)
if resp.http_response.status_code == 416:
raise
chunk = await self.response.internal_response.content.read(self.block_size)
try:
resp = await self.pipeline.run(self.request, stream=True, headers=headers)
if not resp.http_response:
raise
if resp.http_response.status_code == 416:
Comment thread
xiangyan99 marked this conversation as resolved.
Outdated
raise
chunk = await self.response.internal_response.content.read(self.block_size)
Comment thread
xiangyan99 marked this conversation as resolved.
Outdated
except Exception as err: # pylint: disable=broad-except

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious - prior to your change - which except clause was catching and swallowing these errors?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious - prior to your change - which except clause was catching and swallowing these errors?

It fails in https://github.com/psf/requests/blob/master/requests/models.py#L920,

self.status_code is None.

_LOGGER.warning("Unable to stream download: %s", err)
self.response.internal_response.close()
raise ex
if not chunk:
raise StopAsyncIteration()
self.downloaded += len(chunk)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,24 @@ def __next__(self):
self.response.internal_response.close()
raise StopIteration()
except (requests.exceptions.ChunkedEncodingError,
requests.exceptions.ConnectionError):
requests.exceptions.ConnectionError) as ex:
retry_total -= 1
if retry_total <= 0:
retry_active = False
else:
time.sleep(retry_interval)
headers = {'range': 'bytes=' + str(self.downloaded) + '-'}

@ghost ghost Feb 19, 2021

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm starting to repeat myself at least 3 times over, but again: the original request saved in self.request can already have a range set, e.g. range: bytes=1000-. So what does this new range request do, exactly?

Also note that there are REST APIs that have special range request headers that take precedence over range, e.g. azure blob store GET used x-ms-range. For more information please refer to the documentation here: https://docs.microsoft.com/en-us/rest/api/storageservices/specifying-the-range-header-for-blob-service-operations

So it would not even help to try parse this header of the original request here, because one can never be sure to catch all the special cases of APIs here.

Again, please do not retry, this cannot be done safely.

@ghost ghost Feb 19, 2021

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should also add that, in general self.downloaded is unreliable, because it is computed inconsistently, sometimes as

self.downloaded += len(chunk)

and sometimes as

self.downloaded += self._block_size

Both of them can be wrong, in general: requests configures urllib3 to do decoding based on the content-encoding header of the response (e.g. gunzip the data stream):
https://github.com/psf/requests/blob/bdc00eb0978dd3cdd43f7cd1f95ced7aa75fab39/requests/models.py#L753-L754
So this will (usually) read block_size bytes from the underlying raq data stream, then decode these bytes to return chunk. So self.downloaded += len(chunk) will be wrong if such decoding is happening.

However, note that urllib3's stream function that is documented here:
https://urllib3.readthedocs.io/en/1.26.3/reference/urllib3.response.html#urllib3.response.HTTPResponse.stream
actually does not promise to always read exactly block_size bytes from the underlying stream. And in general, it won't: in the current version, for chunked http responses, the block_size is not actually used; instead, the chunks are iterated over.

So in essence, there is no way to reliably know how many of the original bytes were downloaded unless it's clear there is the special case of no http response chunking and no content decoding, and relying on implementation details of urllib3.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @xiangyan99,

regarding the real word scenarios. We are using the azure blob storage in our machine learning applications that pull parquet data from the azure blob storage. Parquet files implement row group statistics, so a common optimisation is to only read the parts of the parquet file that have the row group statistics and then do pruning based on predicates.

See https://github.com/mbr/simplekv/blob/master/simplekv/net/_azurestore_new.py#L194 for a file like interface to access the azure blob storage and http://peter-hoffmann.com/2020/understand-predicate-pushdown-on-rowgroup-level-in-parquet-with-pyarrow-and-python.html or an explanation about row group pruning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @jochen-ott-by @hoffmann , your information is really helpful.

Let me re-think about it. :)

resp = self.pipeline.run(self.request, stream=True, headers=headers)
if resp.http_response.status_code == 416:
raise
chunk = next(self.iter_content_func)
try:
resp = self.pipeline.run(self.request, stream=True, headers=headers)
if not resp.http_response:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?
Maybe we could add a small in-line comment as to what this if-clause is intending to catch?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

raise
if resp.http_response.status_code == 416:
raise
chunk = next(self.iter_content_func)
Comment thread
xiangyan99 marked this conversation as resolved.
Outdated
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Unable to stream download: %s", err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional nit:
From a customers perspective - will this code always be in the context of a "download"? Do we stream in other customer usecases?
Maybe it could be more generic with something like "Unable to stream response content"..... but maybe that sounds too HTTP-ish.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.response.internal_response.close()
raise ex
if not chunk:
raise StopIteration()
self.downloaded += len(chunk)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import requests
from azure.core.pipeline.transport import (
HttpRequest,
AsyncHttpResponse,
AsyncHttpTransport,
AsyncioRequestsTransportResponse,
AioHttpTransport,
)
from azure.core.pipeline import AsyncPipeline
from azure.core.pipeline.transport._aiohttp import AioHttpStreamDownloadGenerator
Expand Down Expand Up @@ -105,3 +108,50 @@ async def __call__(self, *args, **kwargs):
with mock.patch('asyncio.sleep', new_callable=AsyncMock):
with pytest.raises(ConnectionError):
await stream.__anext__()

@pytest.mark.asyncio
async def test_response_streaming_error_behavior():
# Test to reproduce https://github.com/Azure/azure-sdk-for-python/issues/16723
block_size = 103
total_response_size = 500
req_response = requests.Response()
req_request = requests.Request()

class FakeStreamWithConnectionError:
# fake object for urllib3.response.HTTPResponse

def stream(self, chunk_size, decode_content=False):
assert chunk_size == block_size
left = total_response_size
while left > 0:
if left <= block_size:
raise ConnectionError()
data = b"X" * min(chunk_size, left)
left -= len(data)
yield data

def close(self):
pass

req_response.raw = FakeStreamWithConnectionError()

response = AsyncioRequestsTransportResponse(
req_request,
req_response,
block_size,
)

async def mock_run(self, *args, **kwargs):
return PipelineResponse(
None,
requests.Response(),
None,
)

transport = AioHttpTransport()
pipeline = AsyncPipeline(transport)
pipeline.run = mock_run
downloader = response.stream_download(pipeline)
with pytest.raises(ConnectionError):
while True:
await downloader.__anext__()
49 changes: 48 additions & 1 deletion sdk/core/azure-core/tests/test_stream_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
HttpRequest,
HttpResponse,
HttpTransport,
RequestsTransport,
RequestsTransportResponse,
)
from azure.core.pipeline import Pipeline, PipelineResponse
from azure.core.pipeline.transport._requests_basic import StreamDownloadGenerator
Expand Down Expand Up @@ -98,4 +100,49 @@ def close(self):
stream = StreamDownloadGenerator(pipeline, http_response)
with mock.patch('time.sleep', return_value=None):
with pytest.raises(requests.exceptions.ConnectionError):
stream.__next__()
stream.__next__()

def test_response_streaming_error_behavior():
# Test to reproduce https://github.com/Azure/azure-sdk-for-python/issues/16723
block_size = 103
total_response_size = 500
req_response = requests.Response()
req_request = requests.Request()

class FakeStreamWithConnectionError:
# fake object for urllib3.response.HTTPResponse

def stream(self, chunk_size, decode_content=False):
assert chunk_size == block_size
left = total_response_size
while left > 0:
if left <= block_size:
raise requests.exceptions.ConnectionError()
data = b"X" * min(chunk_size, left)
left -= len(data)
yield data

def close(self):
pass

req_response.raw = FakeStreamWithConnectionError()

response = RequestsTransportResponse(
req_request,
req_response,
block_size,
)

def mock_run(self, *args, **kwargs):
return PipelineResponse(
None,
requests.Response(),
None,
)

transport = RequestsTransport()
pipeline = Pipeline(transport)
pipeline.run = mock_run
downloader = response.stream_download(pipeline)
with pytest.raises(requests.exceptions.ConnectionError):
full_response = b"".join(downloader)