o2o-castad-backend/error_plan.md

17 KiB

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

변경 전:

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

변경 후:

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

추가할 내용:

# ============================================================
# 외부 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):

import httpx
import json
from typing import Optional

from app.utils.logger import get_logger

logger = get_logger("suno")

변경 후:

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):

async with httpx.AsyncClient() as client:
    response = await client.post(
        f"{self.base_url}/generate",
        headers=self.headers,
        json=payload,
        timeout=30.0,
    )

변경 후:

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):

timeout=30.0,

변경 후:

timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,

변경 전 (라인 201):

timeout=120.0,

변경 후:

timeout=recovery_settings.SUNO_LYRIC_TIMEOUT,

이유: 환경변수로 타임아웃을 관리하여 배포 환경별로 유연하게 조정할 수 있습니다.


4. Suno API 재시도 로직 추가

파일: app/utils/suno.py

변경 전 - generate() 메서드:

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()

변경 후:

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):

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")

변경 후:

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):

self._client = httpx.AsyncClient(
    base_url=self.BASE_URL,
    headers=self._get_headers(),
    timeout=httpx.Timeout(60.0, connect=10.0),
)

변경 후:

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):

timeout=30.0

변경 후:

timeout=recovery_settings.CREATOMATE_DEFAULT_TIMEOUT

변경 전 (라인 446, 457):

timeout=60.0

변경 후:

timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT

7. Creatomate API 재시도 로직 추가

파일: app/utils/creatomate.py

변경 전 - render_with_json() 메서드 (라인 440~):

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()

변경 후:

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.pyvideo_task.py에 이미 구현되어 있습니다.
  • Response failed 상태: 모든 스키마에 success, status 필드가 이미 존재합니다.
  • 재시도는 5xx 서버 오류와 타임아웃에만 적용되며, 4xx 클라이언트 오류는 즉시 실패 처리합니다.
  • 모든 타임아웃/재시도 설정은 RecoverySettings에서 통합 관리합니다.