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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user