feat: better retries

This commit is contained in:
h
2026-02-17 22:50:50 +01:00
parent ba0e23af4a
commit 73222e0772
2 changed files with 49 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "fastdownloader" name = "fastdownloader"
version = "0.2.0" version = "0.3.0"
description = "Simple parallel file downloader" description = "Simple parallel file downloader"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -8,8 +8,9 @@ import aiohttp
from tqdm import tqdm from tqdm import tqdm
CHUNK_READ_SIZE = 65536 CHUNK_READ_SIZE = 65536
MAX_RETRIES = 3 MAX_RETRIES = 5
RETRY_DELAYS = [1, 2, 4] RETRY_DELAYS = [1, 2, 4, 8, 16]
_RETRYABLE = (TimeoutError, OSError, aiohttp.ClientError)
async def fetch_chunk( # noqa: PLR0913 async def fetch_chunk( # noqa: PLR0913
@@ -34,7 +35,7 @@ async def fetch_chunk( # noqa: PLR0913
f.write(chunk) f.write(chunk)
bytes_written += len(chunk) bytes_written += len(chunk)
pbar.update(len(chunk)) pbar.update(len(chunk))
except (TimeoutError, aiohttp.ClientError): except _RETRYABLE:
pbar.update(-bytes_written) pbar.update(-bytes_written)
if attempt < MAX_RETRIES - 1: if attempt < MAX_RETRIES - 1:
await asyncio.sleep(RETRY_DELAYS[attempt]) await asyncio.sleep(RETRY_DELAYS[attempt])
@@ -47,6 +48,9 @@ async def fetch_chunk( # noqa: PLR0913
async def fetch_single_stream( async def fetch_single_stream(
session: aiohttp.ClientSession, url: str, filepath: Path, pbar: tqdm, session: aiohttp.ClientSession, url: str, filepath: Path, pbar: tqdm,
) -> None: ) -> None:
for attempt in range(MAX_RETRIES):
bytes_written = 0
try:
async with session.get(url) as response: async with session.get(url) as response:
with filepath.open("wb") as f: with filepath.open("wb") as f:
while True: while True:
@@ -54,7 +58,16 @@ async def fetch_single_stream(
if not chunk: if not chunk:
break break
f.write(chunk) f.write(chunk)
bytes_written += len(chunk)
pbar.update(len(chunk)) pbar.update(len(chunk))
except _RETRYABLE:
pbar.update(-bytes_written)
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(RETRY_DELAYS[attempt])
else:
raise
else:
return
def get_filename(response: aiohttp.ClientResponse) -> str: def get_filename(response: aiohttp.ClientResponse) -> str:
@@ -85,12 +98,31 @@ def get_filename(response: aiohttp.ClientResponse) -> str:
return unquote(filename) return unquote(filename)
async def download_file(url: str, num_parts: int = 20, *, position: int = 0) -> None: async def _head_with_retry(
async with aiohttp.ClientSession() as session: session: aiohttp.ClientSession, url: str,
) -> tuple[str, str | None, str]:
for attempt in range(MAX_RETRIES):
try:
async with session.head(url, allow_redirects=True) as response: async with session.head(url, allow_redirects=True) as response:
filename = get_filename(response) return (
content_length_str = response.headers.get("Content-Length") get_filename(response),
accept_ranges = response.headers.get("Accept-Ranges", "") response.headers.get("Content-Length"),
response.headers.get("Accept-Ranges", ""),
)
except _RETRYABLE:
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(RETRY_DELAYS[attempt])
else:
raise
raise RuntimeError("unreachable") # pragma: no cover
async def download_file(url: str, num_parts: int = 20, *, position: int = 0) -> None:
timeout = aiohttp.ClientTimeout(total=None, connect=30, sock_read=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
filename, content_length_str, accept_ranges = await _head_with_retry(
session, url,
)
supports_ranges = accept_ranges == "bytes" and content_length_str is not None supports_ranges = accept_ranges == "bytes" and content_length_str is not None