Skip to content

Commit 0d325fc

Browse files
authored
Merge pull request #359 from ynput/bugfix/download-resume-integrity
Fix corrupted or incomplete files after interrupted download
2 parents 631b2fd + 867b1de commit 0d325fc

3 files changed

Lines changed: 80 additions & 2 deletions

File tree

‎ayon_api/server_api.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,14 @@ def _download_file_to_stream(
16861686
progress.set_destination_url(url)
16871687
continue
16881688
response.raise_for_status()
1689+
if offset > 0 and response.status_code != 206:
1690+
# Server ignored 'Range' and sends whole file again,
1691+
# already downloaded content must be discarded
1692+
stream.seek(0)
1693+
stream.truncate()
1694+
progress.reset_transferred()
1695+
headers.pop("Range", None)
1696+
16891697
if progress.get_content_size() is None:
16901698
progress.set_content_size(
16911699
response.headers["Content-length"]
@@ -1694,11 +1702,21 @@ def _download_file_to_stream(
16941702
for chunk in response.iter_content(chunk_size=chunk_size):
16951703
stream.write(chunk)
16961704
progress.add_transferred_chunk(len(chunk))
1705+
1706+
content_size = progress.get_content_size()
1707+
transferred = progress.get_transferred_size()
1708+
if content_size is not None and transferred != content_size:
1709+
# Connection was closed before all content was received
1710+
raise requests.exceptions.ConnectionError(
1711+
f"Downloaded {transferred} out of {content_size}"
1712+
f" bytes from '{url}'."
1713+
)
16971714
break
16981715

16991716
except (
17001717
requests.exceptions.Timeout,
17011718
requests.exceptions.ConnectionError,
1719+
requests.exceptions.ChunkedEncodingError,
17021720
):
17031721
if attempt == retries - 1:
17041722
raise

‎ayon_api/utils.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -955,7 +955,7 @@ def get_content_size(self) -> int | None:
955955
"""
956956
return self._content_size
957957

958-
def set_content_size(self, content_size: int) -> None:
958+
def set_content_size(self, content_size: int | str) -> None:
959959
"""Set content size in bytes.
960960
961961
Args:
@@ -967,7 +967,7 @@ def set_content_size(self, content_size: int) -> None:
967967
"""
968968
if self._content_size is not None:
969969
raise ValueError("Content size was set more then once")
970-
self._content_size = content_size
970+
self._content_size = int(content_size)
971971

972972
def get_started(self) -> bool:
973973
"""Transfer was started.

‎tests/test_download_resume.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Interrupted downloads. Does not require running AYON server."""
2+
import io
3+
4+
import pytest
5+
6+
from ayon_api.server_api import ServerAPI
7+
from ayon_api.utils import RequestTypes
8+
9+
from .fake_transfer import FakeResponse
10+
11+
CONTENT = b"0123456789"
12+
SIZE_HEADER = {"Content-Length": str(len(CONTENT))}
13+
14+
15+
@pytest.fixture
16+
def con(monkeypatch):
17+
monkeypatch.setattr("time.sleep", lambda *args, **kwargs: None)
18+
return ServerAPI("http://localhost:0", create_session=False, max_retries=3)
19+
20+
21+
def _download(con, get_func):
22+
con._base_functions_mapping[RequestTypes.get] = get_func
23+
stream = io.BytesIO()
24+
progress = con.download_file_to_stream("api/file", stream)
25+
return stream.getvalue(), progress
26+
27+
28+
def test_incomplete_download_is_continued(con):
29+
def get_func(url, **kwargs):
30+
if "Range" not in kwargs["headers"]:
31+
# Connection closed after 4 bytes without an exception
32+
return FakeResponse(200, CONTENT[:4], SIZE_HEADER)
33+
assert kwargs["headers"]["Range"] == "bytes=4-"
34+
return FakeResponse(206, CONTENT[4:], {"Content-Length": "6"})
35+
36+
content, progress = _download(con, get_func)
37+
assert content == CONTENT
38+
assert progress.transferred_size == len(CONTENT)
39+
40+
41+
def test_resume_without_range_support_does_not_duplicate(con):
42+
calls = []
43+
44+
def get_func(url, **kwargs):
45+
calls.append(url)
46+
if len(calls) == 1:
47+
return FakeResponse(200, CONTENT[:4], SIZE_HEADER)
48+
# Server ignores 'Range' header and sends whole file again
49+
return FakeResponse(200, CONTENT, SIZE_HEADER)
50+
51+
content, progress = _download(con, get_func)
52+
assert content == CONTENT
53+
assert progress.transferred_size == len(CONTENT)
54+
55+
56+
def test_download_without_content_length(con):
57+
content, _ = _download(
58+
con, lambda url, **kwargs: FakeResponse(200, CONTENT)
59+
)
60+
assert content == CONTENT

0 commit comments

Comments
 (0)