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:
parent
65b4949245
commit
248782493c
1 changed files with 22 additions and 4 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue