# Suno API & Creatomate API 에러 처리 작업 계획서 ## 현재 상태 분석 ### ✅ 이미 구현된 항목 | 항목 | Suno API | Creatomate API | |------|----------|----------------| | DB failed 상태 저장 | ✅ `song_task.py` | ✅ `video_task.py` | | Response failed 상태 반환 | ✅ `song_schema.py` | ✅ `video_schema.py` | ### ❌ 미구현 항목 | 항목 | Suno API | Creatomate API | |------|----------|----------------| | 타임아웃 외부화 | ❌ 하드코딩됨 | ❌ 하드코딩됨 | | 재시도 로직 | ❌ 없음 | ❌ 없음 | | 커스텀 예외 클래스 | ❌ 없음 | ❌ 없음 | --- ## 1. RecoverySettings에 Suno/Creatomate 설정 추가 ### 파일: `config.py` **변경 전:** ```python class RecoverySettings(BaseSettings): """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 응답 실패 시 최대 재시도 횟수", ) model_config = _base_config ``` **변경 후:** ```python class RecoverySettings(BaseSettings): """외부 API 복구 및 타임아웃 설정 ChatGPT, Suno, Creatomate API의 타임아웃 및 재시도 설정을 관리합니다. """ # ============================================================ # 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 응답 실패 시 최대 재시도 횟수", ) # ============================================================ # 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 기본 요청 타임아웃 (초)", ) 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 ``` --- ## 2. Suno API 커스텀 예외 클래스 추가 ### 파일: `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 오류를 명확히 구분하고 재시도 로직에서 활용합니다. --- ## 3. Suno API 타임아웃 적용 ### 파일: `app/utils/suno.py` **변경 전 (라인 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, ) ``` **변경 후:** ```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, ) ``` **변경 전 (라인 173):** ```python timeout=30.0, ``` **변경 후:** ```python timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT, ``` **변경 전 (라인 201):** ```python timeout=120.0, ``` **변경 후:** ```python timeout=recovery_settings.SUNO_LYRIC_TIMEOUT, ``` **이유:** 환경변수로 타임아웃을 관리하여 배포 환경별로 유연하게 조정할 수 있습니다. --- ## 4. Suno API 재시도 로직 추가 ### 파일: `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`에서 통합 관리합니다.