Files
siftlode/backend/tests/test_youtube_refusals.py
T
peter 2e1934689f fix(search): a search that never reached YouTube gets its slot back, and a busy one waits a bounded time
The slot was kept as soon as search.list was about to be called, so a search refused before it went out (no connection, no token) cost the user a slot for nothing. It is kept now only once the client recorded the call's units, or the request went out and its answer never came (YouTube may have counted it): YouTubeError says whether a transport failure was sent, and the client counts what it spent.

The process lock around the budget check and the call is waited for at most ten seconds; past that the search is refused as busy (search_busy, a retry can help) and gets its slot back, instead of parking a request worker behind every slow call before it. The lock test now pins one hold around both steps, and the busy test fails on a wait without a bound.
2026-10-25 13:51:25 +01:00

238 lines
9.7 KiB
Python

"""An action YouTube refuses answers a 422 the UI can translate — never a raw 500 — and says what the
user can do about it (`routes/_common.youtube_refusal`). The two manual syncs had no handler
at all, so a quota or transport failure opened the blocking "Server error" dialog."""
from types import SimpleNamespace
import httpx
import pytest
from app.auth import has_read_scope
from app.models import OAuthToken
from app.catalog.models import Channel
from app.sync.explore import explore_ingest_page
from app.sync.videos import backfill_channel_deep, backfill_channel_recent
from app.youtube import client as yt
from app.youtube.client import YouTubeError, refusal_reason
@pytest.mark.parametrize(
("exc", "reason"),
[
(YouTubeError("m", reason="quotaExceeded", path="playlists"), "quota"),
(YouTubeError("m", reason="backendError,dailyLimitExceeded", path="videos"), "quota"),
(YouTubeError("m", reason="invalid_grant", path="oauth2/token"), "reconnect"),
# The token endpoint failing any other way leaves the grant alone — and a reconnect would
# not help: Google's bad minute, a rate limit, a misconfigured client.
(YouTubeError("m", reason="backend_error", path="oauth2/token"), "unavailable"),
(YouTubeError("m", reason="<unparseable>", path="oauth2/token"), "unavailable"),
(YouTubeError("m", reason="invalid_client", path="oauth2/token"), "unavailable"),
(YouTubeError("No refresh token; user must re-authenticate"), "reconnect"),
(YouTubeError("m", reason="transport"), "unavailable"),
(YouTubeError("m", reason="backendError", path="subscriptions"), "unavailable"),
# A missing playlist the user never named (a channel's uploads): nothing they can act on.
(YouTubeError("m", reason="playlistNotFound", path="playlistItems"), "unavailable"),
(YouTubeError("m", reason="emptyAnswer", path="playlistItems"), "unavailable"),
],
)
def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
assert refusal_reason(exc) == reason
def test_a_missing_playlist_is_gone_only_on_a_route_about_that_playlist():
# A playlist deleted on YouTube: neither a retry nor a reconnect helps (it said "reconnect").
# Said only where the user picked that one list — anywhere else "this playlist no longer
# exists" names nothing on their screen.
exc = YouTubeError("m", reason="playlistNotFound", path="playlists")
assert refusal_reason(exc, playlist=True) == "gone"
assert refusal_reason(exc) == "unavailable"
assert refusal_reason(YouTubeError("m", reason="quotaExceeded"), playlist=True) == "quota"
def test_a_channel_without_uploads_explores_as_an_empty_page():
# YouTube answers a channel's uploads playlist as not found when the channel has no uploads.
class _Yt:
def get_playlist_items(self, *_):
raise YouTubeError("m", reason="playlistNotFound", path="playlistItems")
channel = SimpleNamespace(id="UCempty", uploads_playlist_id="UUempty")
assert explore_ingest_page(None, _Yt(), channel) == {"ingested": 0, "next_page_token": None}
class _NoUploads:
"""A client whose every uploads read answers "playlist not found" (a channel with no uploads)."""
def __init__(self):
self.calls = 0
def get_playlist_items(self, *_):
self.calls += 1
raise YouTubeError("m", reason="playlistNotFound", path="playlistItems")
def test_a_channel_without_uploads_backfills_as_done_and_is_not_charged_again(db):
# The recent pass stamped nothing on this answer, so the channel was picked — and charged a
# quota unit, with an ERROR traceback — on every backfill run, forever.
ch = Channel(id="UCempty", title="Empty", uploads_playlist_id="UUempty")
db.add(ch)
db.commit()
yt = _NoUploads()
assert backfill_channel_recent(db, yt, ch) == 0
db.refresh(ch)
assert ch.recent_synced_at is not None and ch.backfill_done and ch.backfill_cursor is None
assert backfill_channel_deep(db, yt, ch) == 0
assert yt.calls == 1 # the deep pass has nothing left to ask
def test_a_deep_backfill_whose_uploads_vanish_ends_instead_of_failing(db):
ch = Channel(id="UCgone", title="Gone", uploads_playlist_id="UUgone", backfill_cursor="page2")
db.add(ch)
db.commit()
assert backfill_channel_deep(db, _NoUploads(), ch) == 0
db.refresh(ch)
assert ch.backfill_done and ch.backfill_cursor is None
def test_any_other_backfill_failure_still_raises(db):
class _Yt:
def get_playlist_items(self, *_):
raise YouTubeError("m", reason="quotaExceeded", path="playlistItems")
ch = Channel(id="UCq", title="Q", uploads_playlist_id="UUq")
db.add(ch)
db.commit()
with pytest.raises(YouTubeError):
backfill_channel_recent(db, _Yt(), ch)
def test_any_other_explore_failure_still_raises():
class _Yt:
def get_playlist_items(self, *_):
raise YouTubeError("m", reason="quotaExceeded", path="playlistItems")
channel = SimpleNamespace(id="UCx", uploads_playlist_id="UUx")
with pytest.raises(YouTubeError):
explore_ingest_page(None, _Yt(), channel)
@pytest.mark.parametrize(
("route", "target"),
[
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
("/api/playlists/sync-youtube", "app.routes.playlists.pull_user_playlists"),
],
)
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(
db, as_user, make_user, monkeypatch, route, target
):
user = make_user()
db.commit()
module, name = target.rsplit(".", 1)
def refused(*a, **k):
raise YouTubeError("GET subscriptions -> 403: quotaExceeded", reason="quotaExceeded")
monkeypatch.setattr(f"{module}.has_read_scope", lambda u: True)
monkeypatch.setattr(f"{module}.{name}", refused)
r = as_user(user).post(route)
assert r.status_code == 422
assert r.json()["detail"] == {"code": "youtube", "reason": "quota"}
def test_a_revoked_grant_is_forgotten_so_the_account_can_reconnect(db, make_user, monkeypatch):
"""Google's `invalid_grant` is final. Kept, the grant read as live in Settings — with no connect
button — while every call failed; cleared, the connect button comes back."""
user = make_user()
db.add(
OAuthToken(
user_id=user.id,
refresh_token_enc="x",
scopes="openid https://www.googleapis.com/auth/youtube.readonly",
)
)
db.commit()
db.refresh(user)
assert has_read_scope(user)
resp = httpx.Response(
400,
json={"error": "invalid_grant", "error_description": "Token has been expired or revoked."},
request=httpx.Request("POST", yt.GOOGLE_TOKEN_URL),
)
c = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
c.db, c.user = db, user
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "")
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
with pytest.raises(YouTubeError) as caught:
c._access_token()
assert refusal_reason(caught.value) == "reconnect"
db.expire_all()
assert not has_read_scope(db.get(type(user), user.id))
def test_any_other_token_refusal_keeps_the_grant(monkeypatch):
"""Only `invalid_grant` is final: a 5xx from the token endpoint is Google having a bad minute —
and the user is told to try again, not to reconnect an account that still reads as connected."""
resp = httpx.Response(
503, json={"error": "backend_error"}, request=httpx.Request("POST", yt.GOOGLE_TOKEN_URL)
)
tok = SimpleNamespace(access_token=None, expiry=None, refresh_token_enc="x", scopes="s")
c = yt.YouTubeClient.__new__(yt.YouTubeClient)
c.db = SimpleNamespace(add=lambda o: None, commit=lambda: None)
c.user = SimpleNamespace(id=1, token=tok)
monkeypatch.setattr(yt, "decrypt_optional", lambda value: None)
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "")
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
with pytest.raises(YouTubeError) as caught:
c._access_token()
assert tok.scopes == "s"
assert refusal_reason(caught.value) == "unavailable"
@pytest.mark.parametrize(
("failure", "sent"),
[
(httpx.ConnectError("refused"), False),
(httpx.ConnectTimeout("slow connect"), False),
(httpx.ProxyError("proxy down"), False),
(httpx.ReadTimeout("no answer"), True),
(httpx.RemoteProtocolError("dropped"), True),
],
)
def test_a_transport_failure_says_whether_the_request_went_out(failure, sent):
"""A search that never reached YouTube spent nothing and gets its slot back; one whose answer
never came may have been counted, and keeps it (`routes/search`)."""
def request(*_a, **_k):
raise failure
c = yt.YouTubeClient.__new__(yt.YouTubeClient)
c._http = SimpleNamespace(request=request)
with pytest.raises(YouTubeError) as caught:
c._send("GET", "https://example.invalid")
assert caught.value.reason == "transport"
assert caught.value.sent is sent
def test_a_call_youtube_answered_is_spent_a_refused_one_too(monkeypatch):
resp = httpx.Response(
400,
json={"error": {"errors": [{"reason": "invalidPageToken"}]}},
request=httpx.Request("GET", f"{yt.API_BASE}/search"),
)
recorded: list[int] = []
c = yt.YouTubeClient.__new__(yt.YouTubeClient)
c.db, c.spent = None, 0
monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "a-key")
monkeypatch.setattr(yt.quota, "record_usage", recorded.append)
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
with pytest.raises(YouTubeError):
c._get("search", {}, cost=100)
assert (recorded, c.spent) == ([100], 100)