diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 738ab88..22f9a55 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -64,7 +64,8 @@ KOREAN_CITIES = [ ] # fmt: on -router = APIRouter(tags=["Home"]) +# router = APIRouter(tags=["Home"]) +router = APIRouter() def _extract_region_from_address(road_address: str | None) -> str: diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index f333dfb..a1bfe54 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -36,12 +36,29 @@ from typing import Literal import httpx from app.utils.logger import get_logger -from config import apikey_settings, creatomate_settings +from config import apikey_settings, creatomate_settings, recovery_settings # 로거 설정 logger = get_logger("creatomate") +class CreatomateResponseError(Exception): + """Creatomate API 응답 오류 시 발생하는 예외 + + Creatomate API 렌더링 실패 또는 비정상 응답 시 사용됩니다. + 재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다. + + Attributes: + message: 에러 메시지 + original_response: 원본 API 응답 (있는 경우) + """ + + def __init__(self, message: str, original_response: dict | None = None): + self.message = message + self.original_response = original_response + super().__init__(self.message) + + # Orientation 타입 정의 OrientationType = Literal["horizontal", "vertical"] @@ -135,7 +152,10 @@ async def get_shared_client() -> httpx.AsyncClient: global _shared_client if _shared_client is None or _shared_client.is_closed: _shared_client = httpx.AsyncClient( - timeout=httpx.Timeout(60.0, connect=10.0), + timeout=httpx.Timeout( + recovery_settings.CREATOMATE_RENDER_TIMEOUT, + connect=recovery_settings.CREATOMATE_CONNECT_TIMEOUT, + ), limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), ) return _shared_client @@ -217,7 +237,7 @@ class CreatomateService: self, method: str, url: str, - timeout: float = 30.0, + timeout: float | None = None, **kwargs, ) -> httpx.Response: """HTTP 요청을 수행합니다. @@ -225,7 +245,7 @@ class CreatomateService: Args: method: HTTP 메서드 ("GET", "POST", etc.) url: 요청 URL - timeout: 요청 타임아웃 (초) + timeout: 요청 타임아웃 (초). None이면 기본값 사용 **kwargs: httpx 요청에 전달할 추가 인자 Returns: @@ -236,15 +256,18 @@ class CreatomateService: """ logger.info(f"[Creatomate] {method} {url}") + # timeout이 None이면 기본 타임아웃 사용 + actual_timeout = timeout if timeout is not None else recovery_settings.CREATOMATE_DEFAULT_TIMEOUT + client = await get_shared_client() if method.upper() == "GET": response = await client.get( - url, headers=self.headers, timeout=timeout, **kwargs + url, headers=self.headers, timeout=actual_timeout, **kwargs ) elif method.upper() == "POST": response = await client.post( - url, headers=self.headers, timeout=timeout, **kwargs + url, headers=self.headers, timeout=actual_timeout, **kwargs ) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -255,7 +278,7 @@ class CreatomateService: async def get_all_templates_data(self) -> dict: """모든 템플릿 정보를 조회합니다.""" url = f"{self.BASE_URL}/v1/templates" - response = await self._request("GET", url, timeout=30.0) + response = await self._request("GET", url) # 기본 타임아웃 사용 response.raise_for_status() return response.json() @@ -288,7 +311,7 @@ class CreatomateService: # API 호출 url = f"{self.BASE_URL}/v1/templates/{template_id}" - response = await self._request("GET", url, timeout=30.0) + response = await self._request("GET", url) # 기본 타임아웃 사용 response.raise_for_status() data = response.json() @@ -433,30 +456,147 @@ class CreatomateService: async def make_creatomate_call( self, template_id: str, modifications: dict ) -> dict: - """Creatomate에 렌더링 요청을 보냅니다. + """Creatomate에 렌더링 요청을 보냅니다 (재시도 로직 포함). + + Args: + template_id: Creatomate 템플릿 ID + modifications: 수정사항 딕셔너리 + + Returns: + Creatomate API 응답 데이터 + + Raises: + CreatomateResponseError: API 오류 또는 재시도 실패 시 Note: response에 요청 정보가 있으니 폴링 필요 """ url = f"{self.BASE_URL}/v2/renders" - data = { + payload = { "template_id": template_id, "modifications": modifications, } - response = await self._request("POST", url, timeout=60.0, json=data) - response.raise_for_status() - return response.json() + + last_error: Exception | None = None + + for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1): + try: + response = await self._request( + "POST", + url, + timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT, + json=payload, + ) + + if response.status_code == 200 or response.status_code == 201: + return response.json() + + # 재시도 불가능한 오류 (4xx 클라이언트 오류) + if 400 <= response.status_code < 500: + raise CreatomateResponseError( + f"Client error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text}, + ) + + # 재시도 가능한 오류 (5xx 서버 오류) + last_error = CreatomateResponseError( + f"Server error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text}, + ) + + except httpx.TimeoutException as e: + logger.warning( + f"[Creatomate] Timeout on attempt {attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES + 1}" + ) + last_error = e + + except httpx.HTTPError as e: + logger.warning(f"[Creatomate] HTTP error on attempt {attempt + 1}: {e}") + last_error = e + + except CreatomateResponseError: + raise # CreatomateResponseError는 재시도하지 않고 즉시 전파 + + # 마지막 시도가 아니면 재시도 + if attempt < recovery_settings.CREATOMATE_MAX_RETRIES: + logger.info( + f"[Creatomate] Retrying... ({attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES})" + ) + + # 모든 재시도 실패 + raise CreatomateResponseError( + f"All {recovery_settings.CREATOMATE_MAX_RETRIES + 1} attempts failed", + original_response={"last_error": str(last_error)}, + ) async def make_creatomate_custom_call(self, source: dict) -> dict: - """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. + """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다 (재시도 로직 포함). + + Args: + source: 렌더링 소스 딕셔너리 + + Returns: + Creatomate API 응답 데이터 + + Raises: + CreatomateResponseError: API 오류 또는 재시도 실패 시 Note: response에 요청 정보가 있으니 폴링 필요 """ url = f"{self.BASE_URL}/v2/renders" - response = await self._request("POST", url, timeout=60.0, json=source) - response.raise_for_status() - return response.json() + + last_error: Exception | None = None + + for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1): + try: + response = await self._request( + "POST", + url, + timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT, + json=source, + ) + + if response.status_code == 200 or response.status_code == 201: + return response.json() + + # 재시도 불가능한 오류 (4xx 클라이언트 오류) + if 400 <= response.status_code < 500: + raise CreatomateResponseError( + f"Client error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text}, + ) + + # 재시도 가능한 오류 (5xx 서버 오류) + last_error = CreatomateResponseError( + f"Server error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text}, + ) + + except httpx.TimeoutException as e: + logger.warning( + f"[Creatomate] Timeout on attempt {attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES + 1}" + ) + last_error = e + + except httpx.HTTPError as e: + logger.warning(f"[Creatomate] HTTP error on attempt {attempt + 1}: {e}") + last_error = e + + except CreatomateResponseError: + raise # CreatomateResponseError는 재시도하지 않고 즉시 전파 + + # 마지막 시도가 아니면 재시도 + if attempt < recovery_settings.CREATOMATE_MAX_RETRIES: + logger.info( + f"[Creatomate] Retrying... ({attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES})" + ) + + # 모든 재시도 실패 + raise CreatomateResponseError( + f"All {recovery_settings.CREATOMATE_MAX_RETRIES + 1} attempts failed", + original_response={"last_error": str(last_error)}, + ) # 하위 호환성을 위한 별칭 (deprecated) async def make_creatomate_custom_call_async(self, source: dict) -> dict: @@ -485,7 +625,7 @@ class CreatomateService: - failed: 실패 """ url = f"{self.BASE_URL}/v1/renders/{render_id}" - response = await self._request("GET", url, timeout=30.0) + response = await self._request("GET", url) # 기본 타임아웃 사용 response.raise_for_status() return response.json() diff --git a/app/utils/suno.py b/app/utils/suno.py index 663b813..87611ef 100644 --- a/app/utils/suno.py +++ b/app/utils/suno.py @@ -59,8 +59,28 @@ from typing import Any, List, Optional import httpx -from config import apikey_settings from app.song.schemas.song_schema import PollingSongResponse, SongClipData +from app.utils.logger import get_logger +from config import apikey_settings, recovery_settings + +logger = get_logger("suno") + + +class SunoResponseError(Exception): + """Suno API 응답 오류 시 발생하는 예외 + + Suno API 거부 응답 또는 비정상 응답 시 사용됩니다. + 재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다. + + Attributes: + message: 에러 메시지 + original_response: 원본 API 응답 (있는 경우) + """ + + def __init__(self, message: str, original_response: dict | None = None): + self.message = message + self.original_response = original_response + super().__init__(self.message) class SunoService: @@ -122,34 +142,74 @@ class SunoService: if genre: payload["style"] = genre - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.BASE_URL}/generate", - headers=self.headers, - json=payload, - timeout=30.0, - ) - response.raise_for_status() - data = response.json() + last_error: Exception | None = None - # 응답: {"code": 200, "msg": "success", "data": {"taskId": "..."}} - # API 응답 검증 - if data is None: - raise ValueError("Suno API returned empty response") + for attempt in range(recovery_settings.SUNO_MAX_RETRIES + 1): + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.BASE_URL}/generate", + headers=self.headers, + json=payload, + timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT, + ) - if data.get("code") != 200: - error_msg = data.get("msg", "Unknown error") - raise ValueError(f"Suno API error: {error_msg}") + if response.status_code == 200: + data = response.json() - response_data = data.get("data") - if response_data is None: - raise ValueError(f"Suno API response missing 'data' field: {data}") + # API 응답 검증 + if data is None: + raise SunoResponseError("Suno API returned empty response") - task_id = response_data.get("taskId") - if task_id is None: - raise ValueError(f"Suno API response missing 'taskId': {response_data}") + if data.get("code") != 200: + error_msg = data.get("msg", "Unknown error") + raise SunoResponseError(f"Suno API error: {error_msg}", original_response=data) - return task_id + response_data = data.get("data") + if response_data is None: + raise SunoResponseError(f"Suno API response missing 'data' field", original_response=data) + + task_id = response_data.get("taskId") + if task_id is None: + raise SunoResponseError(f"Suno API response missing 'taskId'", original_response=response_data) + + return task_id + + # 재시도 불가능한 오류 (4xx 클라이언트 오류) + if 400 <= response.status_code < 500: + raise SunoResponseError( + f"Client error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text}, + ) + + # 재시도 가능한 오류 (5xx 서버 오류) + last_error = SunoResponseError( + f"Server error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text}, + ) + + except httpx.TimeoutException as e: + logger.warning( + f"[Suno] Timeout on attempt {attempt + 1}/{recovery_settings.SUNO_MAX_RETRIES + 1}" + ) + last_error = e + + except httpx.HTTPError as e: + logger.warning(f"[Suno] HTTP error on attempt {attempt + 1}: {e}") + last_error = e + + except SunoResponseError: + raise # SunoResponseError는 재시도하지 않고 즉시 전파 + + # 마지막 시도가 아니면 재시도 + if attempt < recovery_settings.SUNO_MAX_RETRIES: + logger.info(f"[Suno] Retrying... ({attempt + 1}/{recovery_settings.SUNO_MAX_RETRIES})") + + # 모든 재시도 실패 + raise SunoResponseError( + f"All {recovery_settings.SUNO_MAX_RETRIES + 1} attempts failed", + original_response={"last_error": str(last_error)}, + ) async def get_task_status(self, task_id: str) -> dict[str, Any]: """ @@ -170,7 +230,7 @@ class SunoService: f"{self.BASE_URL}/generate/record-info", headers=self.headers, params={"taskId": task_id}, - timeout=30.0, + timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT, ) response.raise_for_status() data = response.json() @@ -198,7 +258,7 @@ class SunoService: f"{self.BASE_URL}/generate/get-timestamped-lyrics", headers=self.headers, json=payload, - timeout=120.0, + timeout=recovery_settings.SUNO_LYRIC_TIMEOUT, ) response.raise_for_status() data = response.json() diff --git a/config.py b/config.py index 8fc8749..6feb831 100644 --- a/config.py +++ b/config.py @@ -155,8 +155,14 @@ class PromptSettings(BaseSettings): class RecoverySettings(BaseSettings): - """ChatGPT API 복구 및 타임아웃 설정""" + """외부 API 복구 및 타임아웃 설정 + ChatGPT, Suno, Creatomate API의 타임아웃 및 재시도 설정을 관리합니다. + """ + + # ============================================================ + # ChatGPT API 설정 + # ============================================================ CHATGPT_TIMEOUT: float = Field( default=600.0, description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)", @@ -166,6 +172,42 @@ class RecoverySettings(BaseSettings): description="ChatGPT API 응답 실패 시 최대 재시도 횟수", ) + # ============================================================ + # Suno API 설정 + # ============================================================ + SUNO_DEFAULT_TIMEOUT: float = Field( + default=30.0, + description="Suno API 기본 요청 타임아웃 (초) - 음악 생성 요청, 상태 조회 등", + ) + SUNO_LYRIC_TIMEOUT: float = Field( + default=120.0, + description="Suno API 가사 타임스탬프 요청 타임아웃 (초) - 가사 동기화 처리에 더 긴 시간 필요", + ) + SUNO_MAX_RETRIES: int = Field( + default=2, + description="Suno API 응답 실패 시 최대 재시도 횟수", + ) + + # ============================================================ + # Creatomate API 설정 + # ============================================================ + CREATOMATE_DEFAULT_TIMEOUT: float = Field( + default=30.0, + description="Creatomate API 기본 요청 타임아웃 (초) - 템플릿 조회, 상태 조회 등 일반 API 호출", + ) + CREATOMATE_RENDER_TIMEOUT: float = Field( + default=60.0, + description="Creatomate API 렌더링 요청 타임아웃 (초) - 영상 렌더링 요청 시 더 긴 시간 필요", + ) + CREATOMATE_CONNECT_TIMEOUT: float = Field( + default=10.0, + description="Creatomate API 연결 타임아웃 (초) - 서버 연결 수립까지의 대기 시간", + ) + CREATOMATE_MAX_RETRIES: int = Field( + default=2, + description="Creatomate API 응답 실패 시 최대 재시도 횟수", + ) + model_config = _base_config class KakaoSettings(BaseSettings): diff --git a/error_plan.md b/error_plan.md index 26bb97e..043cc1d 100644 --- a/error_plan.md +++ b/error_plan.md @@ -1,178 +1,571 @@ -# ChatGPT API 에러 처리 개선 계획서 +# Suno API & Creatomate API 에러 처리 작업 계획서 -## 1. 현황 분석 +## 현재 상태 분석 -### 1.1 `generate_structured_output` 사용처 +### ✅ 이미 구현된 항목 +| 항목 | Suno API | Creatomate API | +|------|----------|----------------| +| DB failed 상태 저장 | ✅ `song_task.py` | ✅ `video_task.py` | +| Response failed 상태 반환 | ✅ `song_schema.py` | ✅ `video_schema.py` | -| 파일 | 용도 | DB 상태 업데이트 | 응답 상태 변수 | -|------|------|-----------------|----------------| -| `app/lyric/worker/lyric_task.py` | 가사 생성 | ✅ "failed" 저장 | - (백그라운드) | -| `app/home/api/routers/v1/home.py` | 크롤링 마케팅 분석 | ❌ 없음 | ❌ 없음 | - -### 1.2 응답 스키마 상태 변수 현황 - -| 스키마 | 위치 | 상태 변수 | 조치 | -|--------|------|----------|------| -| `CrawlingResponse` | home_schema.py:158 | ❌ 없음 | `status` 추가 | -| `GenerateLyricResponse` | lyric.py:72 | ✅ `success: bool` | `False`로 설정 | -| `LyricStatusResponse` | lyric.py:105 | ✅ `status: str` | `"failed"` 설정 | -| `LyricDetailResponse` | lyric.py:128 | ✅ `status: str` | `"failed"` 설정 | +### ❌ 미구현 항목 +| 항목 | Suno API | Creatomate API | +|------|----------|----------------| +| 타임아웃 외부화 | ❌ 하드코딩됨 | ❌ 하드코딩됨 | +| 재시도 로직 | ❌ 없음 | ❌ 없음 | +| 커스텀 예외 클래스 | ❌ 없음 | ❌ 없음 | --- -## 2. 개선 목표 +## 1. RecoverySettings에 Suno/Creatomate 설정 추가 -1. **DB 상태 업데이트**: 에러 발생 시 DB에 `status = "failed"` 저장 -2. **클라이언트 응답**: 기존 상태 변수가 있으면 `"failed"` 설정, 없으면 변수 추가 후 설정 +### 파일: `config.py` ---- - -## 3. 상세 작업 계획 - -### 3.1 lyric_task.py - `ChatGPTResponseError` 명시적 처리 - -**파일**: `app/lyric/worker/lyric_task.py` - -**현재 코드 (Line 138-141)**: +**변경 전:** ```python -except Exception as e: - elapsed = (time.perf_counter() - task_start) * 1000 - logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True) - await _update_lyric_status(task_id, "failed", f"Error: {str(e)}") -``` +class RecoverySettings(BaseSettings): + """ChatGPT API 복구 및 타임아웃 설정""" -**변경 코드**: -```python -from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError - -# ... 기존 코드 ... - -except ChatGPTResponseError as e: - elapsed = (time.perf_counter() - task_start) * 1000 - logger.error( - f"[generate_lyric_background] ChatGPT ERROR - task_id: {task_id}, " - f"status: {e.status}, code: {e.error_code} ({elapsed:.1f}ms)" + CHATGPT_TIMEOUT: float = Field( + default=600.0, + description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)", ) - await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}") - -except SQLAlchemyError as e: - # ... 기존 코드 유지 ... - -except Exception as e: - # ... 기존 코드 유지 ... -``` - -**결과**: DB `lyric.status` = `"failed"`, `lyric.lyric_result` = 에러 메시지 - ---- - -### 3.2 home.py - CrawlingResponse에 status 추가 - -**파일**: `app/home/schemas/home_schema.py` - -**현재 코드 (Line 158-168)**: -```python -class CrawlingResponse(BaseModel): - """크롤링 응답 스키마""" - image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록") - image_count: int = Field(..., description="이미지 개수") - processed_info: Optional[ProcessedInfo] = Field(None, ...) - marketing_analysis: Optional[MarketingAnalysis] = Field(None, ...) -``` - -**변경 코드**: -```python -class CrawlingResponse(BaseModel): - """크롤링 응답 스키마""" - status: str = Field( - default="completed", - description="처리 상태 (completed, failed)" + CHATGPT_MAX_RETRIES: int = Field( + default=1, + description="ChatGPT API 응답 실패 시 최대 재시도 횟수", ) - image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록") - image_count: int = Field(..., description="이미지 개수") - processed_info: Optional[ProcessedInfo] = Field(None, ...) - marketing_analysis: Optional[MarketingAnalysis] = Field(None, ...) + + model_config = _base_config ``` ---- - -### 3.3 home.py - 크롤링 엔드포인트 에러 처리 - -**파일**: `app/home/api/routers/v1/home.py` - -**현재 코드 (Line 296-303)**: +**변경 후:** ```python -except Exception as e: - step3_elapsed = (time.perf_counter() - step3_start) * 1000 - logger.error(...) - marketing_analysis = None -``` +class RecoverySettings(BaseSettings): + """외부 API 복구 및 타임아웃 설정 -**변경 코드**: -```python -from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError + ChatGPT, Suno, Creatomate API의 타임아웃 및 재시도 설정을 관리합니다. + """ -# ... 기존 코드 ... - -except ChatGPTResponseError as e: - step3_elapsed = (time.perf_counter() - step3_start) * 1000 - logger.error( - f"[crawling] Step 3 FAILED - ChatGPT Error: {e.status}, {e.error_code} ({step3_elapsed:.1f}ms)" + # ============================================================ + # ChatGPT API 설정 + # ============================================================ + CHATGPT_TIMEOUT: float = Field( + default=600.0, + description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)", + ) + CHATGPT_MAX_RETRIES: int = Field( + default=1, + description="ChatGPT API 응답 실패 시 최대 재시도 횟수", ) - marketing_analysis = None - gpt_status = "failed" -except Exception as e: - step3_elapsed = (time.perf_counter() - step3_start) * 1000 - logger.error(...) - marketing_analysis = None - gpt_status = "failed" + # ============================================================ + # Suno API 설정 + # ============================================================ + SUNO_DEFAULT_TIMEOUT: float = Field( + default=30.0, + description="Suno API 기본 요청 타임아웃 (초)", + ) + SUNO_LYRIC_TIMEOUT: float = Field( + default=120.0, + description="Suno API 가사 타임스탬프 요청 타임아웃 (초)", + ) + SUNO_MAX_RETRIES: int = Field( + default=2, + description="Suno API 응답 실패 시 최대 재시도 횟수", + ) -# 응답 반환 부분 수정 -return { - "status": gpt_status if 'gpt_status' in locals() else "completed", - "image_list": scraper.image_link_list, - "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0, - "processed_info": processed_info, - "marketing_analysis": marketing_analysis, -} + # ============================================================ + # Creatomate API 설정 + # ============================================================ + CREATOMATE_DEFAULT_TIMEOUT: float = Field( + default=30.0, + description="Creatomate API 기본 요청 타임아웃 (초)", + ) + CREATOMATE_RENDER_TIMEOUT: float = Field( + default=60.0, + description="Creatomate API 렌더링 요청 타임아웃 (초)", + ) + CREATOMATE_CONNECT_TIMEOUT: float = Field( + default=10.0, + description="Creatomate API 연결 타임아웃 (초)", + ) + CREATOMATE_MAX_RETRIES: int = Field( + default=2, + description="Creatomate API 응답 실패 시 최대 재시도 횟수", + ) + + model_config = _base_config +``` + +**이유:** 모든 외부 API의 타임아웃/재시도 설정을 `RecoverySettings` 하나에서 통합 관리하여 일관성을 유지합니다. + +### 파일: `.env` + +**추가할 내용:** +```env +# ============================================================ +# 외부 API 타임아웃 및 재시도 설정 (RecoverySettings) +# ============================================================ + +# ChatGPT API (기존) +CHATGPT_TIMEOUT=600.0 +CHATGPT_MAX_RETRIES=1 + +# Suno API +SUNO_DEFAULT_TIMEOUT=30.0 +SUNO_LYRIC_TIMEOUT=120.0 +SUNO_MAX_RETRIES=2 + +# Creatomate API +CREATOMATE_DEFAULT_TIMEOUT=30.0 +CREATOMATE_RENDER_TIMEOUT=60.0 +CREATOMATE_CONNECT_TIMEOUT=10.0 +CREATOMATE_MAX_RETRIES=2 ``` --- -## 4. 파일 변경 요약 +## 2. Suno API 커스텀 예외 클래스 추가 -| 파일 | 변경 내용 | -|------|----------| -| `app/lyric/worker/lyric_task.py` | `ChatGPTResponseError` import 및 명시적 처리 추가 | -| `app/home/schemas/home_schema.py` | `CrawlingResponse`에 `status` 필드 추가 | -| `app/home/api/routers/v1/home.py` | `ChatGPTResponseError` 처리, 응답에 `status` 포함 | +### 파일: `app/utils/suno.py` + +**변경 전 (라인 1-20):** +```python +import httpx +import json +from typing import Optional + +from app.utils.logger import get_logger + +logger = get_logger("suno") +``` + +**변경 후:** +```python +import httpx +import json +from typing import Optional + +from app.utils.logger import get_logger +from config import recovery_settings + +logger = get_logger("suno") + + +class SunoResponseError(Exception): + """Suno API 응답 오류 시 발생하는 예외 + + Suno API 거부 응답 또는 비정상 응답 시 사용됩니다. + 재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다. + + Attributes: + message: 에러 메시지 + original_response: 원본 API 응답 (있는 경우) + """ + def __init__(self, message: str, original_response: dict | None = None): + self.message = message + self.original_response = original_response + super().__init__(self.message) +``` + +**이유:** ChatGPT API와 동일하게 커스텀 예외 클래스를 추가하여 Suno API 오류를 명확히 구분하고 재시도 로직에서 활용합니다. --- -## 5. 변경 후 동작 +## 3. Suno API 타임아웃 적용 -### 5.1 lyric_task.py (가사 생성) +### 파일: `app/utils/suno.py` -| 상황 | DB status | DB lyric_result | -|------|-----------|-----------------| -| 성공 | `"completed"` | 생성된 가사 | -| ChatGPT 에러 | `"failed"` | `"ChatGPT Error: {message}"` | -| DB 에러 | `"failed"` | `"Database Error: {message}"` | -| 기타 에러 | `"failed"` | `"Error: {message}"` | +**변경 전 (라인 130):** +```python +async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/generate", + headers=self.headers, + json=payload, + timeout=30.0, + ) +``` -### 5.2 home.py (크롤링) +**변경 후:** +```python +async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/generate", + headers=self.headers, + json=payload, + timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT, + ) +``` -| 상황 | 응답 status | marketing_analysis | -|------|------------|-------------------| -| 성공 | `"completed"` | 분석 결과 | -| ChatGPT 에러 | `"failed"` | `null` | -| 기타 에러 | `"failed"` | `null` | +**변경 전 (라인 173):** +```python +timeout=30.0, +``` + +**변경 후:** +```python +timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT, +``` + +**변경 전 (라인 201):** +```python +timeout=120.0, +``` + +**변경 후:** +```python +timeout=recovery_settings.SUNO_LYRIC_TIMEOUT, +``` + +**이유:** 환경변수로 타임아웃을 관리하여 배포 환경별로 유연하게 조정할 수 있습니다. --- -## 6. 구현 순서 +## 4. Suno API 재시도 로직 추가 -1. `app/home/schemas/home_schema.py` - `CrawlingResponse`에 `status` 필드 추가 -2. `app/lyric/worker/lyric_task.py` - `ChatGPTResponseError` 명시적 처리 -3. `app/home/api/routers/v1/home.py` - 에러 처리 및 응답 `status` 설정 +### 파일: `app/utils/suno.py` + +**변경 전 - generate() 메서드:** +```python +async def generate( + self, + lyric: str, + style: str, + title: str, + task_id: str, +) -> dict: + """음악 생성 요청""" + payload = { + "prompt": lyric, + "style": style, + "title": title, + "customMode": True, + "callbackUrl": f"{self.callback_url}?task_id={task_id}", + } + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/generate", + headers=self.headers, + json=payload, + timeout=30.0, + ) + + if response.status_code != 200: + logger.error(f"Failed to generate music: {response.text}") + raise Exception(f"Failed to generate music: {response.status_code}") + + return response.json() +``` + +**변경 후:** +```python +async def generate( + self, + lyric: str, + style: str, + title: str, + task_id: str, +) -> dict: + """음악 생성 요청 (재시도 로직 포함) + + Args: + lyric: 가사 텍스트 + style: 음악 스타일 + title: 곡 제목 + task_id: 작업 고유 식별자 + + Returns: + Suno API 응답 데이터 + + Raises: + SunoResponseError: API 오류 또는 재시도 실패 시 + """ + payload = { + "prompt": lyric, + "style": style, + "title": title, + "customMode": True, + "callbackUrl": f"{self.callback_url}?task_id={task_id}", + } + + last_error: Exception | None = None + + for attempt in range(recovery_settings.SUNO_MAX_RETRIES + 1): + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/generate", + headers=self.headers, + json=payload, + timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT, + ) + + if response.status_code == 200: + return response.json() + + # 재시도 불가능한 오류 (4xx 클라이언트 오류) + if 400 <= response.status_code < 500: + raise SunoResponseError( + f"Client error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text} + ) + + # 재시도 가능한 오류 (5xx 서버 오류) + last_error = SunoResponseError( + f"Server error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text} + ) + + except httpx.TimeoutException as e: + logger.warning(f"[Suno] Timeout on attempt {attempt + 1}/{recovery_settings.SUNO_MAX_RETRIES + 1}") + last_error = e + + except httpx.HTTPError as e: + logger.warning(f"[Suno] HTTP error on attempt {attempt + 1}: {e}") + last_error = e + + # 마지막 시도가 아니면 재시도 + if attempt < recovery_settings.SUNO_MAX_RETRIES: + logger.info(f"[Suno] Retrying... ({attempt + 1}/{recovery_settings.SUNO_MAX_RETRIES})") + + # 모든 재시도 실패 + raise SunoResponseError( + f"All {recovery_settings.SUNO_MAX_RETRIES + 1} attempts failed", + original_response={"last_error": str(last_error)} + ) +``` + +**이유:** 네트워크 오류나 일시적인 서버 오류 시 자동으로 재시도하여 안정성을 높입니다. + +--- + +## 5. Creatomate API 커스텀 예외 클래스 추가 + +### 파일: `app/utils/creatomate.py` + +**변경 전 (라인 1-20):** +```python +import asyncio +import json +from typing import Any, Optional + +import httpx + +from app.utils.logger import get_logger +from config import creatomate_settings + +logger = get_logger("creatomate") +``` + +**변경 후:** +```python +import asyncio +import json +from typing import Any, Optional + +import httpx + +from app.utils.logger import get_logger +from config import creatomate_settings, recovery_settings + +logger = get_logger("creatomate") + + +class CreatomateResponseError(Exception): + """Creatomate API 응답 오류 시 발생하는 예외 + + Creatomate API 렌더링 실패 또는 비정상 응답 시 사용됩니다. + 재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다. + + Attributes: + message: 에러 메시지 + original_response: 원본 API 응답 (있는 경우) + """ + def __init__(self, message: str, original_response: dict | None = None): + self.message = message + self.original_response = original_response + super().__init__(self.message) +``` + +--- + +## 6. Creatomate API 타임아웃 적용 + +### 파일: `app/utils/creatomate.py` + +**변경 전 (라인 138):** +```python +self._client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers=self._get_headers(), + timeout=httpx.Timeout(60.0, connect=10.0), +) +``` + +**변경 후:** +```python +self._client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers=self._get_headers(), + timeout=httpx.Timeout( + recovery_settings.CREATOMATE_RENDER_TIMEOUT, + connect=recovery_settings.CREATOMATE_CONNECT_TIMEOUT + ), +) +``` + +**변경 전 (라인 258, 291):** +```python +timeout=30.0 +``` + +**변경 후:** +```python +timeout=recovery_settings.CREATOMATE_DEFAULT_TIMEOUT +``` + +**변경 전 (라인 446, 457):** +```python +timeout=60.0 +``` + +**변경 후:** +```python +timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT +``` + +--- + +## 7. Creatomate API 재시도 로직 추가 + +### 파일: `app/utils/creatomate.py` + +**변경 전 - render_with_json() 메서드 (라인 440~):** +```python +async def render_with_json( + self, + template_id: str, + modifications: dict[str, Any], + task_id: str, +) -> dict: + """JSON 수정사항으로 렌더링 요청""" + payload = { + "template_id": template_id, + "modifications": modifications, + "webhook_url": f"{creatomate_settings.CREATOMATE_CALLBACK_URL}?task_id={task_id}", + } + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.BASE_URL}/v1/renders", + headers=self._get_headers(), + json=payload, + timeout=60.0, + ) + + if response.status_code != 200: + logger.error(f"Failed to render: {response.text}") + raise Exception(f"Failed to render: {response.status_code}") + + return response.json() +``` + +**변경 후:** +```python +async def render_with_json( + self, + template_id: str, + modifications: dict[str, Any], + task_id: str, +) -> dict: + """JSON 수정사항으로 렌더링 요청 (재시도 로직 포함) + + Args: + template_id: Creatomate 템플릿 ID + modifications: 수정사항 딕셔너리 + task_id: 작업 고유 식별자 + + Returns: + Creatomate API 응답 데이터 + + Raises: + CreatomateResponseError: API 오류 또는 재시도 실패 시 + """ + payload = { + "template_id": template_id, + "modifications": modifications, + "webhook_url": f"{creatomate_settings.CREATOMATE_CALLBACK_URL}?task_id={task_id}", + } + + last_error: Exception | None = None + + for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1): + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.BASE_URL}/v1/renders", + headers=self._get_headers(), + json=payload, + timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT, + ) + + if response.status_code == 200: + return response.json() + + # 재시도 불가능한 오류 (4xx 클라이언트 오류) + if 400 <= response.status_code < 500: + raise CreatomateResponseError( + f"Client error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text} + ) + + # 재시도 가능한 오류 (5xx 서버 오류) + last_error = CreatomateResponseError( + f"Server error: {response.status_code}", + original_response={"status": response.status_code, "text": response.text} + ) + + except httpx.TimeoutException as e: + logger.warning(f"[Creatomate] Timeout on attempt {attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES + 1}") + last_error = e + + except httpx.HTTPError as e: + logger.warning(f"[Creatomate] HTTP error on attempt {attempt + 1}: {e}") + last_error = e + + # 마지막 시도가 아니면 재시도 + if attempt < recovery_settings.CREATOMATE_MAX_RETRIES: + logger.info(f"[Creatomate] Retrying... ({attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES})") + + # 모든 재시도 실패 + raise CreatomateResponseError( + f"All {recovery_settings.CREATOMATE_MAX_RETRIES + 1} attempts failed", + original_response={"last_error": str(last_error)} + ) +``` + +--- + +## 작업 체크리스트 + +| 순번 | 작업 내용 | 파일 | 상태 | +|------|----------|------|------| +| 1 | RecoverySettings에 Suno/Creatomate 설정 추가 | `config.py` | ✅ | +| 2 | .env에 타임아웃/재시도 환경변수 추가 | `.env` | ✅ | +| 3 | SunoResponseError 예외 클래스 추가 | `app/utils/suno.py` | ✅ | +| 4 | Suno 타임아웃 적용 (recovery_settings 사용) | `app/utils/suno.py` | ✅ | +| 5 | Suno 재시도 로직 추가 | `app/utils/suno.py` | ✅ | +| 6 | CreatomateResponseError 예외 클래스 추가 | `app/utils/creatomate.py` | ✅ | +| 7 | Creatomate 타임아웃 적용 (recovery_settings 사용) | `app/utils/creatomate.py` | ✅ | +| 8 | Creatomate 재시도 로직 추가 | `app/utils/creatomate.py` | ✅ | + +--- + +## 참고사항 + +- **DB failed 상태 저장**: `song_task.py`와 `video_task.py`에 이미 구현되어 있습니다. +- **Response failed 상태**: 모든 스키마에 `success`, `status` 필드가 이미 존재합니다. +- 재시도는 5xx 서버 오류와 타임아웃에만 적용되며, 4xx 클라이언트 오류는 즉시 실패 처리합니다. +- 모든 타임아웃/재시도 설정은 `RecoverySettings`에서 통합 관리합니다. diff --git a/main.py b/main.py index b205341..c3ba268 100644 --- a/main.py +++ b/main.py @@ -38,10 +38,10 @@ tags_metadata = [ - **Refresh Token**: 7일 유효, Access Token 갱신 시 사용 """, }, - { - "name": "Home", - "description": "홈 화면 및 프로젝트 관리 API", - }, + # { + # "name": "Home", + # "description": "홈 화면 및 프로젝트 관리 API", + # }, { "name": "Crawling", "description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",