Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 13 additions & 2 deletions src/kaggle/api/kaggle_api_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -6285,8 +6285,8 @@ def download_file(
size_read = 0
open_mode = "wb"
self._write_resume_marker(outfile, resume_validator, size)
else:
# e.g. 416 Range Not Satisfiable (local file >= remote size).
elif response.status_code == 416:
# Range Not Satisfiable (local file >= remote size).
# Discard the stale bytes and re-request the full object.
response.close()
response = requests.request(
Expand All @@ -6296,9 +6296,20 @@ def download_file(
stream=True,
timeout=timeout,
)
response.raise_for_status()
size_read = 0
open_mode = "wb"
self._write_resume_marker(outfile, resume_validator, size)
else:
# Any other status (e.g. 403 once the signed URL has expired,
# or a 5xx) carries an error body rather than file content.
# Raise instead of overwriting, so the partial file and its
# resume marker survive for a later attempt.
response.raise_for_status()
raise requests.exceptions.HTTPError(
f"Unexpected status code {response.status_code} when resuming download",
response=response,
)
else:
size_read = 0
open_mode = "wb"
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/test_download_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def __init__(self, blob=b"", etag=None, last_modified="Wed, 01 Jan 2025 00:00:00
self.last_modified = last_modified
self.range_requests = 0 # how many GETs carried a Range header
self.if_range_seen = [] # If-Range values observed
self.deny_status = None # when set, every GET fails with this status


def _make_handler(origin):
Expand All @@ -50,6 +51,15 @@ def _common_headers(self):
self.send_header("Last-Modified", origin.last_modified)

def do_GET(self):
if origin.deny_status is not None:
body = b"<?xml version='1.0' encoding='UTF-8'?><Error><Code>SignatureDoesNotMatch</Code></Error>"
self.send_response(origin.deny_status)
self.send_header("Content-Type", "application/xml; charset=UTF-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return

blob = origin.blob
total = len(blob)
rng = self.headers.get("Range")
Expand Down Expand Up @@ -129,6 +139,7 @@ def setUp(self):
# reset shared origin counters
self.origin.range_requests = 0
self.origin.if_range_seen = []
self.origin.deny_status = None

@property
def url(self):
Expand Down Expand Up @@ -217,6 +228,33 @@ def test_if_range_miss_falls_back_to_overwrite(self):
self.assertEqual(result, b"C" * 150) # overwritten, not "BBB...CCC"
self.assertFalse(self._marker_exists())

def test_http_error_on_resume_preserves_partial(self):
"""An HTTP error during the Range resume (e.g. 403 once the signed URL
has expired) must not overwrite the partial file with the error body,
and must leave resume state usable for a later attempt."""
blob = b"B" * 2000
self._set_remote(blob, etag='"v1"')
self._seed_file(blob[:600])
self._seed_marker('"v1"', 2000)

resp = requests.get(self.url, stream=True) # valid initial response
self.origin.deny_status = 403 # signed URL expires before the resume

with self.assertRaises(requests.exceptions.HTTPError):
self.api.download_file(resp, self.outfile, http_client=None, quiet=True, resume=True, max_retries=0)

# The partial bytes survive untouched...
with open(self.outfile, "rb") as f:
self.assertEqual(f.read(), blob[:600])
# ...and the marker still describes the real object, not the error body.
self.assertTrue(self._marker_exists())
with open(self.api._resume_marker_path(self.outfile)) as f:
self.assertEqual(json.load(f), {"validator": '"v1"', "size": 2000})

# A later run against a working URL therefore resumes to correct content.
self.origin.deny_status = None
self.assertEqual(self._run(), blob)

def test_fresh_download_writes_and_clears_marker(self):
self._set_remote(b"B" * 120)
result = self._run()
Expand Down
Loading