From 993ae150af589d6ab1a273b09873e74032c8d19e Mon Sep 17 00:00:00 2001 From: Pj Metz Date: Thu, 14 May 2026 14:44:52 -0700 Subject: [PATCH] Fix: surface YouTube API errors in slash command instead of silently returning no videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, any HttpError from the YouTube Data API (quota exceeded, invalid key, etc.) was caught in search_recent and silently returned as an empty list. This made it impossible to distinguish a genuine "no videos" result from an API failure — the slash command would always report "⚠️ No recent YouTube videos found" regardless of the cause. Changes: - utils/youtube_api.py: Remove HttpError catch from search_recent so it propagates to callers. get_video_statistics keeps its silent catch (failing to fetch view counts is recoverable). - bot/cogs/youtube_watcher.py: Import HttpError and add a specific except HttpError block in the /youtubedigest slash command that shows the HTTP status code and reason (e.g. quota exceeded) to the user. The background weekly_digest task is unaffected — its outer except Exception block already logs and retries. - tests/test_youtube_api.py: Add test verifying HttpError now propagates from search_recent instead of being swallowed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bot/cogs/youtube_watcher.py | 8 ++++++++ tests/test_youtube_api.py | 28 ++++++++++++++++++++++++++++ utils/youtube_api.py | 33 +++++++++++++-------------------- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/bot/cogs/youtube_watcher.py b/bot/cogs/youtube_watcher.py index 4bfb742..87bb846 100644 --- a/bot/cogs/youtube_watcher.py +++ b/bot/cogs/youtube_watcher.py @@ -36,6 +36,7 @@ import discord from discord import app_commands from discord.ext import commands, tasks +from googleapiclient.errors import HttpError from utils.state import load_state, save_state from utils.youtube_api import YouTubeClient @@ -221,6 +222,13 @@ async def youtubedigest(self, interaction: discord.Interaction) -> None: f"✅ YouTube digest posted to <#{self.discord_channel_id}> ({len(videos)} video(s)).", ephemeral=True, ) + except HttpError as exc: + logger.error("YouTube API error during manual digest for %s: %s", interaction.user, exc) + await interaction.followup.send( + f"❌ YouTube API error (status {exc.status_code}): {exc.reason}\n" + "This is usually a quota issue or invalid API key — check the logs and your Google Cloud console.", + ephemeral=True, + ) except Exception: logger.exception("Manual YouTube digest failed for %s.", interaction.user) await interaction.followup.send( diff --git a/tests/test_youtube_api.py b/tests/test_youtube_api.py index 56d397b..99c0c0a 100644 --- a/tests/test_youtube_api.py +++ b/tests/test_youtube_api.py @@ -1,6 +1,9 @@ import unittest from datetime import datetime, timezone from unittest.mock import patch +from unittest.mock import MagicMock + +from googleapiclient.errors import HttpError from utils.youtube_api import YouTubeClient @@ -48,6 +51,31 @@ def videos(self): class YouTubeApiTests(unittest.TestCase): + def test_search_recent_propagates_http_error(self) -> None: + """HttpError from the API must propagate so callers can distinguish + a genuine empty result from an API failure (e.g. quota exceeded).""" + fake_resp = MagicMock() + fake_resp.status = 403 + fake_resp.reason = "quotaExceeded" + error = HttpError(resp=fake_resp, content=b'{"error":{"message":"quotaExceeded"}}') + + class _ErrorSearchResource: + def list(self, **kwargs): + raise error + + class _ErrorService: + def search(self): + return _ErrorSearchResource() + + client = YouTubeClient.__new__(YouTubeClient) + client._service = _ErrorService() + + with self.assertRaises(HttpError): + client.search_recent( + channel_id="channel-id", + published_after=datetime(2026, 5, 1, tzinfo=timezone.utc), + ) + def test_search_recent_requires_timezone_aware_datetime(self) -> None: client = YouTubeClient.__new__(YouTubeClient) client._service = _FakeService() diff --git a/utils/youtube_api.py b/utils/youtube_api.py index fd61080..363e466 100644 --- a/utils/youtube_api.py +++ b/utils/youtube_api.py @@ -78,7 +78,8 @@ def search_recent( - ``thumbnail`` – URL of the high-quality thumbnail image - ``view_count`` – 0 (placeholder; populate with :meth:`get_video_statistics`) - Returns an empty list when the API call fails. + Raises :class:`googleapiclient.errors.HttpError` if the API call fails + so callers can distinguish a genuine empty result from an API error. """ if published_after.tzinfo is None: raise ValueError( @@ -86,26 +87,18 @@ def search_recent( ) published_after_str = published_after.strftime("%Y-%m-%dT%H:%M:%SZ") - try: - response = ( - self._service.search() - .list( - part="snippet", - channelId=channel_id, - order="date", - type="video", - publishedAfter=published_after_str, - maxResults=max_results, - ) - .execute() + response = ( + self._service.search() + .list( + part="snippet", + channelId=channel_id, + order="date", + type="video", + publishedAfter=published_after_str, + maxResults=max_results, ) - except HttpError as exc: - logger.error( - "YouTube API search error (channel=%s): %s", - channel_id, - exc, - ) - return [] + .execute() + ) videos: List[dict] = [] for item in response.get("items", []):