From 8096837b13746cf9b405c1e2f270d945956043b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 28 Jul 2026 16:07:08 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9C=A0=ED=8A=9C=EB=B8=8C=20=EB=84=A4?= =?UTF-8?q?=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=98=A4=EB=A5=98=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/dashboard/services/youtube_analytics.py | 92 +++++++++++++-------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/app/dashboard/services/youtube_analytics.py b/app/dashboard/services/youtube_analytics.py index 2167775..1271539 100644 --- a/app/dashboard/services/youtube_analytics.py +++ b/app/dashboard/services/youtube_analytics.py @@ -433,6 +433,10 @@ class YouTubeAnalyticsService: logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS") return result + # 5xx/네트워크 오류 재시도 설정 (구글 backendError 등 일시적 장애 대응) + _MAX_RETRIES = 3 + _RETRY_BACKOFF_SECONDS = (0.5, 1.0, 2.0) + async def _call_api( self, params: dict[str, str], @@ -453,51 +457,67 @@ class YouTubeAnalyticsService: Raises: YouTubeQuotaExceededError: 할당량 초과 (429) YouTubeAuthError: 인증 실패 (401, 403) - YouTubeAPIError: 기타 API 오류 + YouTubeAPIError: 기타 API 오류 (5xx/네트워크 오류는 최대 3회 재시도 후 발생) Note: - 타임아웃: 30초 - 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생 - 인증 실패 시 자동으로 YouTubeAuthError 발생 + - 5xx 응답 및 네트워크 오류는 지수 백오프(0.5s→1s→2s)로 최대 3회 재시도 """ headers = {"Authorization": f"Bearer {access_token}"} - try: - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get( - self.BASE_URL, - params=params, - headers=headers, - ) - - # 할당량 초과 체크 - if response.status_code == 429: - logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED") - raise YouTubeQuotaExceededError() - - # 인증 실패 체크 - if response.status_code in (401, 403): - logger.warning( - f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}" + last_error: Exception | None = None + for attempt in range(self._MAX_RETRIES + 1): + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + self.BASE_URL, + params=params, + headers=headers, ) - raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}") - # HTTP 에러 체크 - response.raise_for_status() + # 할당량 초과 체크 + if response.status_code == 429: + logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED") + raise YouTubeQuotaExceededError() - return response.json() + # 인증 실패 체크 + if response.status_code in (401, 403): + logger.warning( + f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}" + ) + raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}") - except (YouTubeAuthError, YouTubeQuotaExceededError): - raise # 이미 처리된 예외는 그대로 전파 - except httpx.HTTPStatusError as e: - logger.error( - f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - " - f"status={e.response.status_code}, body={e.response.text[:500]}" - ) - raise YouTubeAPIError(f"HTTP {e.response.status_code}") - except httpx.RequestError as e: - logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}") - raise YouTubeAPIError(f"네트워크 오류: {e}") - except Exception as e: - logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}") - raise YouTubeAPIError(f"알 수 없는 오류: {e}") + # HTTP 에러 체크 + response.raise_for_status() + + return response.json() + + except (YouTubeAuthError, YouTubeQuotaExceededError): + raise # 이미 처리된 예외는 재시도 없이 그대로 전파 + except httpx.HTTPStatusError as e: + logger.error( + f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - " + f"status={e.response.status_code}, body={e.response.text[:500]}" + ) + # 4xx는 재시도해도 동일하게 실패하므로 즉시 전파, 5xx만 재시도 + if e.response.status_code < 500: + raise YouTubeAPIError(f"HTTP {e.response.status_code}") + last_error = YouTubeAPIError(f"HTTP {e.response.status_code}") + except httpx.RequestError as e: + logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}") + last_error = YouTubeAPIError(f"네트워크 오류: {e}") + except Exception as e: + logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}") + raise YouTubeAPIError(f"알 수 없는 오류: {e}") + + if attempt < self._MAX_RETRIES: + backoff = self._RETRY_BACKOFF_SECONDS[attempt] + logger.warning( + f"[YouTubeAnalyticsService._call_api] RETRY {attempt + 1}/{self._MAX_RETRIES} " + f"in {backoff}s - {last_error}" + ) + await asyncio.sleep(backoff) + + raise last_error