retry network errors and raise request timeout

Медленная/нестабильная сеть до api.music.yandex.net роняла весь процесс
при первом же таймауте (5с по умолчанию у библиотеки). Добавлен
with_retries с экспоненциальной паузой на всех сетевых точках входа
(fetch, language, genres, apply, client.init), таймаут запроса поднят
до 20с (настраивается в config.toml). Заодно поправлен скрытый баг:
language.py раньше кэшировал таймаут как "текста нет" навсегда -
теперь только настоящий NotFoundError трактуется так.
This commit is contained in:
Andrew Pimenov 2026-07-06 10:05:22 +03:00
parent 353be65e3a
commit 942c2d19e4
8 changed files with 59 additions and 21 deletions

View file

@ -3,6 +3,8 @@
fetch_batch_delay = 0.4
lyrics_request_delay = 0.25
apply_request_delay = 0.3
# Таймаут одного запроса, секунды (библиотека по умолчанию ставит 5 - мало при медленной сети)
request_timeout = 20
[fetch]
# Сколько id треков в одном батч-запросе client.tracks(...)

View file

@ -4,14 +4,16 @@ import time
from yandex_music import Client, Playlist
from .ymclient import with_retries
def _existing_playlists_by_title(client: Client) -> dict[str, Playlist]:
playlists = client.users_playlists_list()
playlists = with_retries(lambda: client.users_playlists_list())
return {p.title: p for p in playlists if p.title}
def _existing_track_ids(client: Client, playlist: Playlist) -> set[str]:
full = client.users_playlists(playlist.kind)
full = with_retries(lambda: client.users_playlists(playlist.kind))
if full is None:
return set()
return {t.track_id for t in full.tracks}
@ -20,7 +22,7 @@ def _existing_track_ids(client: Client, playlist: Playlist) -> set[str]:
def _get_or_create_playlist(client: Client, title: str, existing: dict[str, Playlist]) -> Playlist:
if title in existing:
return existing[title]
playlist = client.users_playlists_create(title, visibility="private")
playlist = with_retries(lambda: client.users_playlists_create(title, visibility="private"))
existing[title] = playlist
return playlist
@ -51,11 +53,13 @@ def apply_classification(
if track_key in already:
continue
updated = client.users_playlists_insert_track(
playlist.kind,
row["id"],
row["album_id"],
revision=revision,
updated = with_retries(
lambda: client.users_playlists_insert_track(
playlist.kind,
row["id"],
row["album_id"],
revision=revision,
)
)
if updated is not None and updated.revision is not None:
revision = updated.revision

View file

@ -15,13 +15,13 @@ def cmd_auth(args: argparse.Namespace) -> None:
def cmd_fetch(args: argparse.Namespace) -> None:
cfg = load_config()
client = make_client(auth.get_token(cfg.token_file))
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
fetch.fetch_liked_tracks(client, cfg.cache_dir, cfg.batch_size, cfg.fetch_batch_delay)
def cmd_report(args: argparse.Namespace) -> None:
cfg = load_config()
client = make_client(auth.get_token(cfg.token_file))
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
if not tracks_cache:
@ -53,7 +53,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
raise SystemExit("Это изменит ваш аккаунт. Добавьте флаг --yes для подтверждения.")
cfg = load_config()
client = make_client(auth.get_token(cfg.token_file))
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
if not tracks_cache:

View file

@ -12,6 +12,7 @@ class Config:
fetch_batch_delay: float
lyrics_request_delay: float
apply_request_delay: float
request_timeout: float
batch_size: int
token_file: Path
cache_dir: Path
@ -27,6 +28,7 @@ def load_config(path: Path | None = None) -> Config:
fetch_batch_delay=data["throttle"]["fetch_batch_delay"],
lyrics_request_delay=data["throttle"]["lyrics_request_delay"],
apply_request_delay=data["throttle"]["apply_request_delay"],
request_timeout=data["throttle"]["request_timeout"],
batch_size=data["fetch"]["batch_size"],
token_file=ROOT / data["paths"]["token_file"],
cache_dir=ROOT / data["paths"]["cache_dir"],

View file

@ -6,7 +6,7 @@ from pathlib import Path
from yandex_music import Client, Track
from .ymclient import chunked
from .ymclient import chunked, with_retries
TRACKS_CACHE_FILE = "tracks.json"
@ -53,7 +53,7 @@ def fetch_liked_tracks(
cache_file = cache_dir / TRACKS_CACHE_FILE
cache = load_tracks_cache(cache_dir)
likes = client.users_likes_tracks()
likes = with_retries(lambda: client.users_likes_tracks())
if likes is None:
print("Не удалось получить список лайкнутых треков.")
return cache
@ -64,7 +64,7 @@ def fetch_liked_tracks(
print(f"Лайкнутых треков: {len(all_ids)}, новых для загрузки: {len(missing_ids)}")
for batch in chunked(missing_ids, batch_size):
tracks = client.tracks(batch)
tracks = with_retries(lambda: client.tracks(batch))
for track in tracks:
cache[track.track_id] = _serialize(track)
_atomic_write_json(cache_file, cache)

View file

@ -5,6 +5,8 @@ from pathlib import Path
from yandex_music import Client, Genre
from .ymclient import with_retries
GENRE_CATALOG_FILE = "genre_catalog.json"
# Корневые жанры каталога Яндекс.Музыки -> наши крупные корзины.
@ -83,7 +85,7 @@ def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]:
if cache_file.exists():
return json.loads(cache_file.read_text(encoding="utf-8"))
roots = client.genres()
roots = with_retries(lambda: client.genres())
flat = flatten_catalog(roots)
cache_file.write_text(json.dumps(flat, ensure_ascii=False, indent=2), encoding="utf-8")
return flat

View file

@ -6,7 +6,9 @@ import unicodedata
from pathlib import Path
from yandex_music import Client
from yandex_music.exceptions import YandexMusicError
from yandex_music.exceptions import NotFoundError
from .ymclient import with_retries
LYRICS_LANG_CACHE_FILE = "lyrics_lang.json"
@ -99,8 +101,8 @@ def load_lang_cache(cache_dir: Path) -> dict[str, str | None]:
def _fetch_one_language(client: Client, track_id: str) -> str | None:
try:
supplement = client.track_supplement(track_id)
except YandexMusicError:
supplement = with_retries(lambda: client.track_supplement(track_id))
except NotFoundError:
return None
if supplement is None or supplement.lyrics is None:
return None

View file

@ -1,18 +1,44 @@
from __future__ import annotations
from typing import Iterator, List, TypeVar
import time
from typing import Callable, Iterator, List, TypeVar
from yandex_music import Client
from yandex_music.exceptions import NetworkError, NotFoundError
T = TypeVar("T")
def make_client(token: str) -> Client:
def make_client(token: str, request_timeout: float = 20) -> Client:
client = Client(token=token)
client.init()
client.request.set_timeout(request_timeout)
with_retries(lambda: client.init())
return client
def chunked(items: List[T], size: int) -> Iterator[List[T]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def with_retries(fn: Callable[[], T], max_attempts: int = 4, base_delay: float = 2.0) -> T:
"""Повторяет вызов при временных сетевых сбоях (таймауты и т.п.) с экспоненциальной паузой.
NotFoundError не повторяется - это окончательный ответ API, а не сбой сети,
хотя формально он тоже унаследован от NetworkError.
"""
last_exc: NetworkError | None = None
for attempt in range(max_attempts):
try:
return fn()
except NotFoundError:
raise
except NetworkError as e:
last_exc = e
if attempt < max_attempts - 1:
wait = base_delay * (2**attempt)
print(f" сетевая ошибка ({e}), повтор через {wait:.0f}с...")
time.sleep(wait)
assert last_exc is not None
raise last_exc