fix(youtube): map httpx transport errors to YouTubeError

The YouTubeClient issued httpx requests directly, so a network/transport
failure (egress proxy unreachable, DNS, timeout, connection reset) escaped
as a raw httpx.ConnectError. Callers only guard against YouTubeError, so such
a fault propagated uncaught and surfaced as a 500 — e.g. opening an
un-enriched channel's page (GET /api/channels/{id} lazily enriches About
data) popped a blocking "Server error (500)" modal whenever the fixed-IP
egress proxy was down.

Route all client HTTP through a _send() helper that wraps httpx.HTTPError in
YouTubeError, so every existing 'except YouTubeError' degrades gracefully:
channel detail returns un-enriched (200), explore returns 422 (quiet), and
scheduler jobs log-and-continue instead of crashing.
This commit is contained in:
npeter83 2026-07-09 09:24:09 +02:00
parent 65b4949245
commit 248782493c

View file

@ -55,6 +55,22 @@ class YouTubeClient:
def __exit__(self, *exc) -> None: def __exit__(self, *exc) -> None:
self._http.close() self._http.close()
# --- transport ---
def _send(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Issue an HTTP request, mapping any transport/network failure (egress proxy down,
DNS, timeout, connection reset) to YouTubeError. Every caller already handles
YouTubeError degrade gracefully, log, and move on so wrapping here keeps a
transient network fault (e.g. the fixed-IP egress proxy being unreachable) from
escaping as a raw httpx error and turning into an uncaught 500. HTTP status errors
are NOT touched here (we don't use raise_for_status): callers inspect resp.status_code
themselves and raise their own YouTubeError with the response body."""
try:
return self._http.request(method, url, **kwargs)
except httpx.HTTPError as exc:
raise YouTubeError(
f"YouTube API unreachable ({exc.__class__.__name__}: {exc})"
) from exc
# --- auth --- # --- auth ---
def _access_token(self) -> str: def _access_token(self) -> str:
tok = self.user.token tok = self.user.token
@ -66,7 +82,8 @@ class YouTubeClient:
refresh = decrypt(tok.refresh_token_enc) refresh = decrypt(tok.refresh_token_enc)
if not refresh: if not refresh:
raise YouTubeError("No refresh token; user must re-authenticate") raise YouTubeError("No refresh token; user must re-authenticate")
resp = self._http.post( resp = self._send(
"POST",
GOOGLE_TOKEN_URL, GOOGLE_TOKEN_URL,
data={ data={
"client_id": sysconfig.get_str(self.db, "google_client_id"), "client_id": sysconfig.get_str(self.db, "google_client_id"),
@ -94,7 +111,7 @@ class YouTubeClient:
p["key"] = api_key p["key"] = api_key
else: else:
headers["Authorization"] = f"Bearer {self._access_token()}" headers["Authorization"] = f"Bearer {self._access_token()}"
resp = self._http.get(f"{API_BASE}/{path}", params=p, headers=headers) resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
quota.record_usage(self.db, cost) quota.record_usage(self.db, cost)
if resp.status_code != 200: if resp.status_code != 200:
log.warning("YouTube API %s -> %s: %s", path, resp.status_code, resp.text[:200]) log.warning("YouTube API %s -> %s: %s", path, resp.status_code, resp.text[:200])
@ -210,7 +227,8 @@ class YouTubeClient:
def delete_subscription(self, subscription_id: str) -> None: def delete_subscription(self, subscription_id: str) -> None:
"""Unsubscribe on YouTube. Requires the write scope and the user's OAuth token """Unsubscribe on YouTube. Requires the write scope and the user's OAuth token
(never the public API key). subscriptions.delete costs 50 quota units.""" (never the public API key). subscriptions.delete costs 50 quota units."""
resp = self._http.delete( resp = self._send(
"DELETE",
f"{API_BASE}/subscriptions", f"{API_BASE}/subscriptions",
params={"id": subscription_id}, params={"id": subscription_id},
headers={"Authorization": f"Bearer {self._access_token()}"}, headers={"Authorization": f"Bearer {self._access_token()}"},
@ -268,7 +286,7 @@ class YouTubeClient:
# --- write endpoints (OAuth only, never the API key; each costs 50 quota units) --- # --- write endpoints (OAuth only, never the API key; each costs 50 quota units) ---
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict: def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:
headers = {"Authorization": f"Bearer {self._access_token()}"} headers = {"Authorization": f"Bearer {self._access_token()}"}
resp = self._http.request( resp = self._send(
method, f"{API_BASE}/{path}", params=params, json=json, headers=headers method, f"{API_BASE}/{path}", params=params, json=json, headers=headers
) )
quota.record_usage(self.db, 50) quota.record_usage(self.db, 50)