-
Notifications
You must be signed in to change notification settings - Fork 65
Retry stream read errors in SimpleDownloader #581
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
base: master
Are you sure you want to change the base?
Changes from all commits
9850c24
bcf8297
24b3e57
3db2dd0
86b5ead
3aee93b
6b1d238
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 |
|---|---|---|
|
|
@@ -116,11 +116,7 @@ def is_suitable(self, download_version: DownloadVersion, allow_seeking: bool): | |
| """ | ||
| if self.REQUIRES_SEEKING and not allow_seeking: | ||
| return False | ||
| if ( | ||
| not self.SUPPORTS_DECODE_CONTENT | ||
| and download_version.content_encoding | ||
| and download_version.api.api_config.decode_content | ||
| ): | ||
| if not self.SUPPORTS_DECODE_CONTENT and download_version._should_be_decoded: | ||
|
Collaborator
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.
|
||
| return False | ||
| return True | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ | |
| import logging | ||
| from io import IOBase | ||
|
|
||
| from requests.exceptions import ChunkedEncodingError, ConnectionError, ContentDecodingError | ||
| from requests.models import Response | ||
|
|
||
| from b2sdk._internal.encryption.setting import EncryptionSetting | ||
|
|
@@ -26,6 +27,7 @@ | |
| class SimpleDownloader(AbstractDownloader): | ||
| REQUIRES_SEEKING = False | ||
| SUPPORTS_DECODE_CONTENT = True | ||
| MAX_DOWNLOAD_ATTEMPTS = 5 | ||
|
|
||
| def _download( | ||
| self, | ||
|
|
@@ -41,12 +43,16 @@ def _download( | |
| response.close() | ||
| return 0, digest.hexdigest() | ||
| chunk_size = self._get_chunk_size(actual_size) | ||
| should_be_decoded = download_version._should_be_decoded | ||
|
|
||
| decoded_bytes_read = 0 | ||
| for data in response.iter_content(chunk_size=chunk_size): | ||
| file.write(data) | ||
| digest.update(data) | ||
| decoded_bytes_read += len(data) | ||
| try: | ||
| for data in response.iter_content(chunk_size=chunk_size): | ||
| file.write(data) | ||
| digest.update(data) | ||
| except (ChunkedEncodingError, ConnectionError, ContentDecodingError) as exc: | ||
| if should_be_decoded: | ||
| raise # cannot resume a partially decoded stream | ||
| logger.debug('Stream read error during download, will retry if needed: %s', exc) | ||
| bytes_read = response.raw.tell() | ||
| response.close() | ||
|
|
||
|
|
@@ -58,33 +64,35 @@ def _download( | |
| # or something and the server closes connection, while neither tcp or http have a problem | ||
| # with the truncated output, so we detect it here and try to continue | ||
|
|
||
| num_tries = 5 # this is hardcoded because we are going to replace the entire retry interface soon, so we'll avoid deprecation here and keep it private | ||
|
Collaborator
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 don't think you should un-hardcode it here as when this will turn into an interface that folks can rely on, it will be difficult to maintain the interface over the refactoring. Move to module scoped |
||
| retries_left = num_tries - 1 | ||
| while retries_left and bytes_read < download_version.content_length: | ||
| retries_left = self.MAX_DOWNLOAD_ATTEMPTS - 1 | ||
| while ( | ||
| bytes_read < download_version.content_length and not should_be_decoded and retries_left | ||
| ): | ||
| new_range = self._get_remote_range( | ||
| response, | ||
| download_version, | ||
| ).subrange(bytes_read, actual_size - 1) | ||
| # original response is not closed at this point yet, as another layer is responsible for closing it, so a new socket might be allocated, | ||
| # but this is a very rare case and so it is not worth the optimization | ||
| logger.debug( | ||
| 're-download attempts remaining: %i, bytes read: %i (decoded: %i). Getting range %s now.', | ||
| 're-download attempts remaining: %i, bytes read: %i. Getting range %s now.', | ||
| retries_left, | ||
| bytes_read, | ||
| decoded_bytes_read, | ||
| new_range, | ||
| ) | ||
| with session.download_file_from_url( | ||
| response.request.url, | ||
| new_range.as_tuple(), | ||
| encryption=encryption, | ||
| ) as followup_response: | ||
| for data in followup_response.iter_content( | ||
| chunk_size=self._get_chunk_size(actual_size) | ||
| ): | ||
| file.write(data) | ||
| digest.update(data) | ||
| decoded_bytes_read += len(data) | ||
| try: | ||
| for data in followup_response.iter_content( | ||
| chunk_size=self._get_chunk_size(actual_size) | ||
| ): | ||
| file.write(data) | ||
| digest.update(data) | ||
| except (ChunkedEncodingError, ConnectionError, ContentDecodingError) as exc: | ||
| logger.debug('Stream read error during download, will retry if needed: %s', exc) | ||
| bytes_read += followup_response.raw.tell() | ||
| retries_left -= 1 | ||
| return bytes_read, digest.hexdigest() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| Retry stream read errors during download in `SimpleDownloader`. | ||
|
|
||
| Decoded downloads with `decode_content=True` now validate truncation; previously all post-download checks were skipped for decoded streams. | ||
|
|
||
| Fix `b2sdk.v1.B2Api` not exposing `api_config`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| ###################################################################### | ||
| # | ||
| # File: test/unit/internal/transfer/downloader/test_simple.py | ||
| # | ||
| # Copyright 2026 Backblaze Inc. All Rights Reserved. | ||
| # | ||
| # License https://www.backblaze.com/using_b2_code.html | ||
| # | ||
| ###################################################################### | ||
| import os | ||
| from collections.abc import Callable, Iterator | ||
| from io import BytesIO | ||
| from itertools import count | ||
| from types import ModuleType | ||
| from typing import Any | ||
|
|
||
| import pytest | ||
| from apiver_deps import B2Api, Bucket, DownloadVersion, SimpleDownloader | ||
| from requests.exceptions import ChunkedEncodingError, ConnectionError, ContentDecodingError | ||
| from requests.models import Response | ||
| from urllib3.exceptions import DecodeError, IncompleteRead, ProtocolError, ReadTimeoutError | ||
|
|
||
| CHUNKED_ENCODING_ERROR = ChunkedEncodingError( | ||
| ProtocolError( | ||
| 'Connection broken: IncompleteRead(1 bytes read, 99 more expected)', | ||
| IncompleteRead(1, 99), | ||
| ) | ||
| ) | ||
| CONTENT_DECODING_ERROR = ContentDecodingError( | ||
| DecodeError('Error -3 while decompressing data: incorrect header check') | ||
| ) | ||
| CONNECTION_ERROR = ConnectionError(ReadTimeoutError(None, None, 'Read timed out.')) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def file_size() -> int: | ||
| return 100 | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def file_content(file_size: int) -> bytes: | ||
| return os.urandom(file_size) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def mock_download_response( | ||
| apiver_module: ModuleType, | ||
| bucket: Bucket, | ||
| file_content: bytes, | ||
| ) -> tuple[Response, DownloadVersion]: | ||
| file_version = bucket.upload_bytes(file_content, f'dummy_file_{len(file_content)}.txt') | ||
|
|
||
| url = bucket.api.session.get_download_url_by_name(bucket.name, file_version.file_name) | ||
| response = bucket.api.services.session.download_file_from_url(url).__enter__() | ||
|
|
||
| return ( | ||
| response, | ||
| apiver_module.DownloadVersionFactory(bucket.api).from_response_headers(response.headers), | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def output_file() -> BytesIO: | ||
| return BytesIO() | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def downloader(apiver_module: ModuleType) -> SimpleDownloader: | ||
| return apiver_module.SimpleDownloader(force_chunk_size=5) | ||
|
|
||
|
|
||
| def _make_iter_content( | ||
| response: Response, | ||
| attempts: Iterator[int], | ||
| fail_count: int, | ||
| stream_error: ChunkedEncodingError | ConnectionError | ContentDecodingError, | ||
| ) -> Callable[..., Iterator[bytes]]: | ||
| def iter_content(chunk_size: int = 1, decode_unicode: bool = False) -> Iterator[bytes]: | ||
| attempt = next(attempts) | ||
| chunk = response.raw.read(1) | ||
| if chunk: | ||
| yield chunk | ||
| if attempt <= fail_count: | ||
| raise stream_error | ||
| while True: | ||
| chunk = response.raw.read(chunk_size) | ||
| if not chunk: | ||
| break | ||
| yield chunk | ||
|
|
||
| return iter_content | ||
|
|
||
|
|
||
| @pytest.mark.parametrize('fail_count', [0, 1, 2, 4, 5]) | ||
| @pytest.mark.parametrize( | ||
| 'stream_error', | ||
| [ | ||
| pytest.param(CHUNKED_ENCODING_ERROR, id='ChunkedEncodingError'), | ||
| pytest.param(CONNECTION_ERROR, id='ConnectionError'), | ||
| pytest.param(CONTENT_DECODING_ERROR, id='ContentDecodingError'), | ||
| ], | ||
| ) | ||
| def test_download_file__stream_read_error( | ||
| b2api: B2Api, | ||
| bucket: Bucket, | ||
| downloader: SimpleDownloader, | ||
| output_file: BytesIO, | ||
| file_size: int, | ||
| file_content: bytes, | ||
| mock_download_response: tuple[Response, DownloadVersion], | ||
| fail_count: int, | ||
| stream_error: ChunkedEncodingError | ConnectionError | ContentDecodingError, | ||
| ) -> None: | ||
| mock_response, download_version = mock_download_response | ||
|
|
||
| attempts = count(1) | ||
| mock_response.iter_content = _make_iter_content( | ||
| mock_response, attempts, fail_count, stream_error | ||
| ) | ||
|
|
||
| download_func = bucket.api.services.session.download_file_from_url | ||
|
|
||
| def download_func_mock(*args: Any, **kwargs: Any) -> Response: | ||
| response = download_func(*args, **kwargs).__enter__() | ||
| response.iter_content = _make_iter_content(response, attempts, fail_count, stream_error) | ||
| return response | ||
|
|
||
| bucket.api.services.session.download_file_from_url = download_func_mock | ||
|
|
||
| bytes_written, _ = downloader.download( | ||
| output_file, mock_response, download_version, b2api.session | ||
| ) | ||
|
|
||
| if fail_count < 5: | ||
| assert bytes_written == file_size | ||
| assert output_file.getvalue() == file_content | ||
| else: | ||
| assert bytes_written == fail_count | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| 'stream_error', | ||
| [ | ||
| pytest.param(CHUNKED_ENCODING_ERROR, id='ChunkedEncodingError'), | ||
| pytest.param(CONNECTION_ERROR, id='ConnectionError'), | ||
| pytest.param(CONTENT_DECODING_ERROR, id='ContentDecodingError'), | ||
| ], | ||
| ) | ||
| def test_download_file__decoded_stream_stream_read_error_reraises( | ||
| b2api: B2Api, | ||
| bucket: Bucket, | ||
| downloader: SimpleDownloader, | ||
| output_file: BytesIO, | ||
| file_content: bytes, | ||
| mock_download_response: tuple[Response, DownloadVersion], | ||
| stream_error: ChunkedEncodingError | ConnectionError | ContentDecodingError, | ||
| ) -> None: | ||
| """ | ||
| Test that a stream read error during a decoded stream download is re-raised and not retried | ||
| """ | ||
|
|
||
| mock_response, download_version = mock_download_response | ||
| download_version.content_encoding = 'gzip' | ||
| download_version.api.api_config.decode_content = True | ||
|
|
||
| attempts = count(1) | ||
| mock_response.iter_content = _make_iter_content(mock_response, attempts, 1, stream_error) | ||
|
|
||
| followup_calls = 0 | ||
| download_func = bucket.api.services.session.download_file_from_url | ||
|
|
||
| def download_func_mock(*args: Any, **kwargs: Any) -> Response: | ||
| nonlocal followup_calls | ||
| followup_calls += 1 | ||
| response = download_func(*args, **kwargs).__enter__() | ||
| response.iter_content = _make_iter_content(response, attempts, 1, stream_error) | ||
| return response | ||
|
|
||
| bucket.api.services.session.download_file_from_url = download_func_mock | ||
|
|
||
| with pytest.raises(type(stream_error)): | ||
| downloader.download(output_file, mock_response, download_version, b2api.session) | ||
|
|
||
| assert followup_calls == 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if the rest of the code expects
DownloadVersion-like objects to have this method or else it's going to crash, then we have to add it to interface, meaning we have to document it so that those who inherit will know what it is