Skip to content

Commit 9d7bbfe

Browse files
authored
[Download] Share retry handling for stream entry and body failures (#4826)
* [Download] Share retry handling for stream entry and body failures * [Download] Remove added retry unit tests
1 parent aa3c2fd commit 9d7bbfe

1 file changed

Lines changed: 96 additions & 92 deletions

File tree

src/huggingface_hub/file_download.py

Lines changed: 96 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import time
88
import uuid
99
import warnings
10+
from contextlib import ExitStack
1011
from dataclasses import dataclass
1112
from pathlib import Path
1213
from typing import Any, BinaryIO, Literal, NoReturn, overload
@@ -374,100 +375,103 @@ def http_get(
374375
" Install `hf_xet` with `pip install hf_xet` for xet-powered downloads."
375376
)
376377

377-
with http_stream_backoff(
378-
method="GET",
379-
url=url,
380-
headers=headers,
381-
timeout=constants.HF_HUB_DOWNLOAD_TIMEOUT,
382-
retry_on_exceptions=(),
383-
retry_on_status_codes=(408, 429),
384-
) as response:
385-
hf_raise_for_status(response)
386-
387-
# If we requested a Range but got 200 back, the server ignored our Range header
388-
# (e.g. CloudFront with Accept-Encoding: gzip). Reset file to avoid corruption.
389-
if resume_size > 0 and response.status_code == 200:
390-
temp_file.seek(0)
391-
temp_file.truncate()
392-
if _tqdm_bar is not None:
393-
# When the progress bar is reused across retries, its counter has already been advanced by `resume_size`
394-
# worth of chunks from earlier attempts. Those bytes are gone from disk now, so roll the counter back
395-
# to keep the upcoming full re-download from double-counting (e.g. ending at 130/100 on a 100-byte file).
396-
_tqdm_bar.update(-resume_size)
397-
if callable(update_transfer := getattr(_tqdm_bar, "update_transfer", None)):
398-
update_transfer(-resume_size)
399-
resume_size = 0
400-
401-
total: int | None = _get_file_length_from_http_response(response)
402-
if total is None:
403-
# Hub serves compressible text files (e.g. vocab.json) with `Content-Encoding: gzip` and
404-
# `Transfer-Encoding: chunked`, so the response carries no `Content-Length`. Fall back to the caller's
405-
# `expected_size` (always known from the metadata HEAD on the hf_hub path) so the progress bar, and any
406-
# aggregating wrapper such as snapshot_download's `_AggregatedTqdm` — still sees the file size.
407-
total = expected_size
408-
409-
if displayed_filename is None:
410-
displayed_filename = url
411-
content_disposition = response.headers.get("Content-Disposition")
412-
if content_disposition is not None:
413-
match = HEADER_FILENAME_PATTERN.search(content_disposition)
414-
if match is not None:
415-
# Means file is on CDN
416-
displayed_filename = match.groupdict()["filename"]
417-
418-
# Truncate filename if too long to display
419-
if len(displayed_filename) > 40:
420-
displayed_filename = f"(…){displayed_filename[-40:]}"
421-
422-
consistency_error_message = (
423-
f"Consistency check failed: file should be of size {expected_size} but has size"
424-
f" {{actual_size}} ({displayed_filename}).\nThis is usually due to network issues while downloading the file."
425-
" Please retry with `force_download=True`."
426-
)
427-
progress_cm = _get_progress_bar_context(
428-
desc=displayed_filename,
429-
log_level=logger.getEffectiveLevel(),
430-
total=total,
431-
initial=resume_size,
432-
name="huggingface_hub.http_get",
433-
tqdm_class=tqdm_class,
434-
_tqdm_bar=_tqdm_bar,
435-
)
436-
437-
with progress_cm as progress:
438-
new_resume_size = resume_size
439-
try:
440-
for chunk in response.iter_bytes(chunk_size=constants.DOWNLOAD_CHUNK_SIZE):
441-
if chunk: # filter out keep-alive new chunks
442-
progress.update(len(chunk))
443-
if callable(update_transfer := getattr(progress, "update_transfer", None)):
444-
update_transfer(len(chunk))
445-
temp_file.write(chunk)
446-
new_resume_size += len(chunk)
447-
# Some data has been downloaded from the server so we reset the number of retries.
448-
_nb_retries = 5
449-
except (httpx.ConnectError, httpx.TimeoutException, httpx.RemoteProtocolError) as e:
450-
# If ConnectionError (SSLError), ReadTimeout, or RemoteProtocolError (peer closed the connection before
451-
# sending the complete body) happen while streaming data from the server, it is most likely a transient
452-
# error (network outage?). We log a warning message and try to resume the download a few times before
453-
# giving up. The retry mechanism is basic but should be enough in most cases.
454-
if _nb_retries <= 0:
455-
logger.warning("Error while downloading from %s: %s\nMax retries exceeded.", url, str(e))
456-
raise
457-
logger.warning("Error while downloading from %s: %s\nTrying to resume download...", url, str(e))
458-
time.sleep(1)
459-
return http_get(
378+
# Keep the response and progress bar open while recursive retries reuse them.
379+
with ExitStack() as stack:
380+
progress = _tqdm_bar
381+
new_resume_size = resume_size
382+
try:
383+
response = stack.enter_context(
384+
http_stream_backoff(
385+
method="GET",
460386
url=url,
461-
temp_file=temp_file,
462-
resume_size=new_resume_size,
463-
headers=initial_headers,
464-
expected_size=expected_size,
465-
tqdm_class=tqdm_class,
466-
_nb_retries=_nb_retries - 1,
467-
# Reuse the existing progress bar across retries so a custom `tqdm_class` (e.g. snapshot_download's `_AggregatedTqdm`,
468-
# which mutates a shared parent bar in `__init__`) is not re-instantiated and does not double-count `total`/`initial`.
469-
_tqdm_bar=progress,
387+
headers=headers,
388+
timeout=constants.HF_HUB_DOWNLOAD_TIMEOUT,
389+
retry_on_exceptions=(),
390+
retry_on_status_codes=(408, 429),
470391
)
392+
)
393+
hf_raise_for_status(response)
394+
395+
# If we requested a Range but got 200 back, the server ignored our Range header
396+
# (e.g. CloudFront with Accept-Encoding: gzip). Reset file to avoid corruption.
397+
if resume_size > 0 and response.status_code == 200:
398+
temp_file.seek(0)
399+
temp_file.truncate()
400+
if _tqdm_bar is not None:
401+
# When the progress bar is reused across retries, its counter has already been advanced by `resume_size`
402+
# worth of chunks from earlier attempts. Those bytes are gone from disk now, so roll the counter back
403+
# to keep the upcoming full re-download from double-counting (e.g. ending at 130/100 on a 100-byte file).
404+
_tqdm_bar.update(-resume_size)
405+
if callable(update_transfer := getattr(_tqdm_bar, "update_transfer", None)):
406+
update_transfer(-resume_size)
407+
resume_size = 0
408+
409+
total: int | None = _get_file_length_from_http_response(response)
410+
if total is None:
411+
# Hub serves compressible text files (e.g. vocab.json) with `Content-Encoding: gzip` and
412+
# `Transfer-Encoding: chunked`, so the response carries no `Content-Length`. Fall back to the caller's
413+
# `expected_size` (always known from the metadata HEAD on the hf_hub path) so the progress bar, and any
414+
# aggregating wrapper such as snapshot_download's `_AggregatedTqdm` — still sees the file size.
415+
total = expected_size
416+
417+
if displayed_filename is None:
418+
displayed_filename = url
419+
content_disposition = response.headers.get("Content-Disposition")
420+
if content_disposition is not None:
421+
match = HEADER_FILENAME_PATTERN.search(content_disposition)
422+
if match is not None:
423+
# Means file is on CDN
424+
displayed_filename = match.groupdict()["filename"]
425+
426+
# Truncate filename if too long to display
427+
if len(displayed_filename) > 40:
428+
displayed_filename = f"(…){displayed_filename[-40:]}"
429+
430+
consistency_error_message = (
431+
f"Consistency check failed: file should be of size {expected_size} but has size"
432+
f" {{actual_size}} ({displayed_filename}).\nThis is usually due to network issues while downloading the file."
433+
" Please retry with `force_download=True`."
434+
)
435+
progress_cm = _get_progress_bar_context(
436+
desc=displayed_filename,
437+
log_level=logger.getEffectiveLevel(),
438+
total=total,
439+
initial=resume_size,
440+
name="huggingface_hub.http_get",
441+
tqdm_class=tqdm_class,
442+
_tqdm_bar=_tqdm_bar,
443+
)
444+
445+
progress = stack.enter_context(progress_cm)
446+
new_resume_size = resume_size
447+
for chunk in response.iter_bytes(chunk_size=constants.DOWNLOAD_CHUNK_SIZE):
448+
if chunk: # filter out keep-alive new chunks
449+
progress.update(len(chunk))
450+
if callable(update_transfer := getattr(progress, "update_transfer", None)):
451+
update_transfer(len(chunk))
452+
temp_file.write(chunk)
453+
new_resume_size += len(chunk)
454+
# Some data has been downloaded from the server so we reset the number of retries.
455+
_nb_retries = 5
456+
except (httpx.ConnectError, httpx.TimeoutException, httpx.RemoteProtocolError) as e:
457+
# Retry transient failures both when opening the stream and while reading its body.
458+
if _nb_retries <= 0:
459+
logger.warning("Error while downloading from %s: %s\nMax retries exceeded.", url, str(e))
460+
raise
461+
logger.warning("Error while downloading from %s: %s\nTrying to resume download...", url, str(e))
462+
time.sleep(1)
463+
return http_get(
464+
url=url,
465+
temp_file=temp_file,
466+
resume_size=new_resume_size,
467+
headers=initial_headers,
468+
expected_size=expected_size,
469+
tqdm_class=tqdm_class,
470+
_nb_retries=_nb_retries - 1,
471+
# Reuse the existing progress bar across retries so a custom `tqdm_class` (e.g. snapshot_download's `_AggregatedTqdm`,
472+
# which mutates a shared parent bar in `__init__`) is not re-instantiated and does not double-count `total`/`initial`.
473+
_tqdm_bar=progress,
474+
)
471475

472476
if expected_size is not None and expected_size != temp_file.tell():
473477
raise OSError(

0 commit comments

Comments
 (0)