fix(youtube): a refused YouTube action answers a 422 that says what to do

The two manual syncs (subscriptions import, playlist pull) had no YouTubeError
handler, so a quota, token or transport failure was a raw 500 and the blocking
"Server error" dialog (E9 F1). The other YouTube routes answered either the
upstream's English or a playlist-scoped "youtube_failed".

One helper, routes/_common.youtube_refusal: 422 {code: "youtube", reason} with
the reason sorted by what the user can do — quota (wait for the reset),
reconnect (the token step refused), unavailable (retry). Used by every route
that surfaces a YouTube failure; localizeDetail translates it.

"Reconnect" had no path: a grant Google revoked still read as granted, so
Settings and the wizard offered no connect button while every call failed.
An invalid_grant from the token endpoint now clears the stored grant.
This commit is contained in:
2026-09-19 06:55:40 +02:00
parent 1cfb19bdc7
commit fad301e7a4
15 changed files with 221 additions and 27 deletions
+40 -1
View File
@@ -9,6 +9,7 @@ uses OAuth.
import logging
from collections.abc import Iterator
from datetime import datetime, timedelta, timezone
from typing import Literal
import httpx
@@ -108,6 +109,22 @@ def is_quota_refusal(exc: YouTubeError) -> bool:
return any(r in _QUOTA_REASONS for r in exc.reason.split(","))
# What a refused YouTube action means to the person who asked for it — the three things they can
# act on differently: wait for tomorrow's budget, reconnect the account, or try again later.
YouTubeRefusal = Literal["quota", "reconnect", "unavailable"]
def refusal_reason(exc: YouTubeError) -> YouTubeRefusal:
"""Sort a failure by what the user can do about it. The token step failing (Google refused the
refresh, or there is no usable token at all — raised before any endpoint answered, so with no
path and no transport reason) means the account must be reconnected; retrying cannot help."""
if is_quota_refusal(exc):
return "quota"
if exc.path == "oauth2/token" or (not exc.path and exc.reason != "transport"):
return "reconnect"
return "unavailable"
def _chunks(items: list, size: int) -> Iterator[list]:
for i in range(0, len(items), size):
yield items[i : i + size]
@@ -181,7 +198,10 @@ class YouTubeClient:
},
)
if resp.status_code != 200:
raise _upstream_failure("POST", "oauth2/token", resp)
exc = _upstream_failure("POST", "oauth2/token", resp)
if exc.reason == "invalid_grant":
self._drop_dead_grant(tok)
raise exc
data = resp.json()
access = data["access_token"]
tok.access_token = encrypt(access)
@@ -191,6 +211,25 @@ class YouTubeClient:
log.info("Refreshed access token for user %s", self.user.id)
return access
def _drop_dead_grant(self, tok) -> None:
"""Google answered the refresh with `invalid_grant`: the grant was revoked or expired, and no
retry will revive it. Forget it, so the account reads as NOT connected — Settings and the
onboarding wizard then offer the connect button again (a fresh consent issues a new refresh
token). Kept as "granted", the UI showed a live connection with no way to renew it while
every call failed. Committed like the refresh above: the caller rolls back on the error."""
tok.scopes = None
tok.access_token = None
tok.refresh_token_enc = None
tok.expiry = None
self.db.add(tok)
self.db.commit()
# INFO: the refusal itself is already the warning (`youtube.api.error`, invalid_grant).
log.info(
"cleared the dead YouTube grant of user %s",
self.user.id,
extra=logs.event("youtube.grant.cleared"),
)
# --- core request ---
def _get(
self,