harden playlist read against None response from users_playlists

client.users_playlists(kind) silently returns None on any response-parse
failure, not just when the playlist genuinely doesn't exist - this isn't
an exception, so with_retries doesn't catch it. Previously this was
treated as "playlist is empty", which risked re-inserting everything on
top of existing tracks. Now retry with backoff and raise instead of
guessing.
This commit is contained in:
Andrew Pimenov 2026-07-16 16:27:01 +03:00
parent 1c814830e5
commit 55b3ee832e
2 changed files with 68 additions and 5 deletions

View file

@ -13,11 +13,30 @@ def _existing_playlists_by_title(client: Client) -> dict[str, Playlist]:
return {p.title: p for p in playlists if p.title}
def _existing_track_ids(client: Client, playlist: Playlist) -> set[str]:
full = with_retries(lambda: client.users_playlists(playlist.kind))
if full is None:
return set()
return {t.track_id for t in full.tracks}
def _existing_track_ids(client: Client, playlist: Playlist, max_attempts: int = 4) -> set[str]:
"""Возвращает set track_id уже находящихся в плейлисте треков.
client.users_playlists(kind) может вернуть None не только когда плейлиста не существует,
но и при любом сбое разбора ответа (усечённое/повреждённое тело при 200 OK) - это тихий
фолбэк библиотеки, а не исключение, поэтому with_retries его не перехватывает. Плейлист
только что получен из users_playlists_list(), так что None здесь почти наверняка означает
именно такой сбой, а не "плейлист пуст". Раньше это тихо трактовалось как пустой плейлист,
из-за чего apply_classification вставлял все треки заново поверх уже существующих (дубли).
Теперь повторяем запрос и, если так и не получили данные, падаем явно вместо угадывания.
"""
for attempt in range(max_attempts):
full = with_retries(lambda: client.users_playlists(playlist.kind))
if full is not None:
return {t.track_id for t in full.tracks}
if attempt < max_attempts - 1:
wait = 2.0 * (2**attempt)
print(f" пустой ответ при чтении плейлиста «{playlist.title}», повтор через {wait:.0f}с...")
time.sleep(wait)
raise RuntimeError(
f"не удалось прочитать содержимое плейлиста «{playlist.title}» (kind={playlist.kind}) "
"после нескольких попыток - прерываю, чтобы не вставить треки повторно поверх существующих"
)
def _get_or_create_playlist(client: Client, title: str, existing: dict[str, Playlist]) -> Playlist:

View file

@ -135,3 +135,47 @@ def test_apply_continues_after_bad_request_on_single_track():
apply_mod.apply_classification(client, rows, delay=0)
assert client.inserted == [(200, 2, 20, 1)], "второй трек должен вставиться, несмотря на ошибку первого"
class NoneOnceClient(FakeClient):
"""users_playlists возвращает None (сбой разбора ответа) на первой попытке, затем нормальные данные."""
def __init__(self):
super().__init__()
self.calls = 0
def users_playlists(self, kind):
self.calls += 1
if self.calls == 1:
return None
return super().users_playlists(kind)
def test_apply_retries_on_none_response_instead_of_treating_as_empty_playlist(monkeypatch):
# Регрессия: client.users_playlists() тихо возвращает None при любом сбое разбора ответа
# (не только когда плейлиста реально не существует). Раньше это трактовалось как "плейлист
# пуст", и apply вставлял поверх уже существующих треков дубли.
monkeypatch.setattr(apply_mod.time, "sleep", lambda s: None)
client = NoneOnceClient()
rows = [{"target_playlist": "Рок — RU", "id": 1, "album_id": 10}]
apply_mod.apply_classification(client, rows, delay=0)
assert client.inserted == [], "трек уже был в плейлисте, дубль вставляться не должен"
class AlwaysNoneClient(FakeClient):
def users_playlists(self, kind):
return None
def test_apply_raises_instead_of_silently_treating_persistent_none_as_empty(monkeypatch):
monkeypatch.setattr(apply_mod.time, "sleep", lambda s: None)
client = AlwaysNoneClient()
rows = [{"target_playlist": "Рок — RU", "id": 1, "album_id": 10}]
try:
apply_mod.apply_classification(client, rows, delay=0)
assert False, "должно было упасть, а не молча решить, что плейлист пуст"
except RuntimeError:
pass