diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index bf2293c..9695a55 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -55,6 +55,22 @@ class YouTubeClient: def __exit__(self, *exc) -> None: 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 --- def _access_token(self) -> str: tok = self.user.token @@ -66,7 +82,8 @@ class YouTubeClient: refresh = decrypt(tok.refresh_token_enc) if not refresh: raise YouTubeError("No refresh token; user must re-authenticate") - resp = self._http.post( + resp = self._send( + "POST", GOOGLE_TOKEN_URL, data={ "client_id": sysconfig.get_str(self.db, "google_client_id"), @@ -94,7 +111,7 @@ class YouTubeClient: p["key"] = api_key else: 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) if resp.status_code != 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: """Unsubscribe on YouTube. Requires the write scope and the user's OAuth token (never the public API key). subscriptions.delete costs 50 quota units.""" - resp = self._http.delete( + resp = self._send( + "DELETE", f"{API_BASE}/subscriptions", params={"id": subscription_id}, 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) --- def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict: 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 ) quota.record_usage(self.db, 50)