o2o-castad-backend/docs/plan/fix_plan.md

11 KiB

API 타임아웃 및 재시도 로직 개선 계획

개요

외부 API 호출 시 타임아웃 미설정 및 재시도 로직 부재로 인한 안정성 문제를 해결합니다.


현재 상태

모듈 외부 API 타임아웃 재시도
Lyric ChatGPT (OpenAI) 미설정 (SDK 기본 ~600초) 없음
Song Suno API 30-120초 없음
Video Creatomate API 30-60초 없음

수정 계획

1. ChatGPT API 타임아웃 설정

파일: app/utils/chatgpt_prompt.py

현재 코드:

class ChatgptService:
    def __init__(self):
        self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)

수정 코드:

class ChatgptService:
    # 타임아웃 설정 (초)
    DEFAULT_TIMEOUT = 60.0  # 전체 타임아웃
    CONNECT_TIMEOUT = 10.0  # 연결 타임아웃

    def __init__(self):
        self.client = AsyncOpenAI(
            api_key=apikey_settings.CHATGPT_API_KEY,
            timeout=httpx.Timeout(
                self.DEFAULT_TIMEOUT,
                connect=self.CONNECT_TIMEOUT,
            ),
        )

필요한 import 추가:

import httpx

2. 재시도 유틸리티 함수 생성

파일: app/utils/retry.py (새 파일)

"""
API 호출 재시도 유틸리티

지수 백오프(Exponential Backoff)를 사용한 재시도 로직을 제공합니다.
"""

import asyncio
import logging
from functools import wraps
from typing import Callable, Tuple, Type

logger = logging.getLogger(__name__)


class RetryExhaustedError(Exception):
    """모든 재시도 실패 시 발생하는 예외"""
    def __init__(self, message: str, last_exception: Exception):
        super().__init__(message)
        self.last_exception = last_exception


async def retry_async(
    func: Callable,
    max_retries: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
    exponential_base: float = 2.0,
    retry_on: Tuple[Type[Exception], ...] = (Exception,),
    on_retry: Callable[[int, Exception], None] | None = None,
):
    """
    비동기 함수 재시도 실행

    Args:
        func: 실행할 비동기 함수 (인자 없음)
        max_retries: 최대 재시도 횟수 (기본: 3)
        base_delay: 첫 번째 재시도 대기 시간 (초)
        max_delay: 최대 대기 시간 (초)
        exponential_base: 지수 백오프 배수 (기본: 2.0)
        retry_on: 재시도할 예외 타입들
        on_retry: 재시도 시 호출될 콜백 (attempt, exception)

    Returns:
        함수 실행 결과

    Raises:
        RetryExhaustedError: 모든 재시도 실패 시

    Example:
        result = await retry_async(
            lambda: api_call(),
            max_retries=3,
            retry_on=(httpx.TimeoutException, httpx.HTTPStatusError),
        )
    """
    last_exception = None

    for attempt in range(max_retries + 1):
        try:
            return await func()
        except retry_on as e:
            last_exception = e

            if attempt == max_retries:
                break

            # 지수 백오프 계산
            delay = min(base_delay * (exponential_base ** attempt), max_delay)

            logger.warning(
                f"[retry_async] 시도 {attempt + 1}/{max_retries + 1} 실패, "
                f"{delay:.1f}초 후 재시도: {type(e).__name__}: {e}"
            )

            if on_retry:
                on_retry(attempt + 1, e)

            await asyncio.sleep(delay)

    raise RetryExhaustedError(
        f"최대 재시도 횟수({max_retries + 1}회) 초과",
        last_exception,
    )


def with_retry(
    max_retries: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
    retry_on: Tuple[Type[Exception], ...] = (Exception,),
):
    """
    재시도 데코레이터

    Args:
        max_retries: 최대 재시도 횟수
        base_delay: 첫 번째 재시도 대기 시간 (초)
        max_delay: 최대 대기 시간 (초)
        retry_on: 재시도할 예외 타입들

    Example:
        @with_retry(max_retries=3, retry_on=(httpx.TimeoutException,))
        async def call_api():
            ...
    """
    def decorator(func: Callable):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            return await retry_async(
                lambda: func(*args, **kwargs),
                max_retries=max_retries,
                base_delay=base_delay,
                max_delay=max_delay,
                retry_on=retry_on,
            )
        return wrapper
    return decorator

3. Suno API 재시도 로직 적용

파일: app/utils/suno.py

수정 대상 메서드:

  • generate() - 노래 생성 요청
  • get_task_status() - 상태 조회
  • get_lyric_timestamp() - 타임스탬프 조회

수정 예시 (generate 메서드):

# 상단 import 추가
import httpx
from app.utils.retry import retry_async

# 재시도 대상 예외 정의
RETRY_EXCEPTIONS = (
    httpx.TimeoutException,
    httpx.ConnectError,
    httpx.ReadError,
)

async def generate(
    self,
    prompt: str,
    genre: str | None = None,
    callback_url: str | None = None,
) -> str:
    # ... 기존 payload 구성 코드 ...

    async def _call_api():
        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()
            return response.json()

    # 재시도 로직 적용
    data = await retry_async(
        _call_api,
        max_retries=3,
        base_delay=1.0,
        retry_on=RETRY_EXCEPTIONS,
    )

    # ... 기존 응답 처리 코드 ...

4. Creatomate API 재시도 로직 적용

파일: app/utils/creatomate.py

수정 대상:

  • _request() 메서드 (모든 API 호출의 기반)

수정 코드:

# 상단 import 추가
from app.utils.retry import retry_async

# 재시도 대상 예외 정의
RETRY_EXCEPTIONS = (
    httpx.TimeoutException,
    httpx.ConnectError,
    httpx.ReadError,
)

async def _request(
    self,
    method: str,
    url: str,
    timeout: float = 30.0,
    max_retries: int = 3,
    **kwargs,
) -> httpx.Response:
    """HTTP 요청을 수행합니다 (재시도 로직 포함)."""
    logger.info(f"[Creatomate] {method} {url}")

    async def _call():
        client = await get_shared_client()
        if method.upper() == "GET":
            response = await client.get(
                url, headers=self.headers, timeout=timeout, **kwargs
            )
        elif method.upper() == "POST":
            response = await client.post(
                url, headers=self.headers, timeout=timeout, **kwargs
            )
        else:
            raise ValueError(f"Unsupported HTTP method: {method}")
        response.raise_for_status()
        return response

    response = await retry_async(
        _call,
        max_retries=max_retries,
        base_delay=1.0,
        retry_on=RETRY_EXCEPTIONS,
    )

    logger.info(f"[Creatomate] Response - Status: {response.status_code}")
    return response

5. ChatGPT API 재시도 로직 적용

파일: app/utils/chatgpt_prompt.py

수정 코드:

# 상단 import 추가
import httpx
from openai import APITimeoutError, APIConnectionError, RateLimitError
from app.utils.retry import retry_async

# 재시도 대상 예외 정의
RETRY_EXCEPTIONS = (
    APITimeoutError,
    APIConnectionError,
    RateLimitError,
)

class ChatgptService:
    DEFAULT_TIMEOUT = 60.0
    CONNECT_TIMEOUT = 10.0
    MAX_RETRIES = 3

    def __init__(self):
        self.client = AsyncOpenAI(
            api_key=apikey_settings.CHATGPT_API_KEY,
            timeout=httpx.Timeout(
                self.DEFAULT_TIMEOUT,
                connect=self.CONNECT_TIMEOUT,
            ),
        )

    async def _call_structured_output_with_response_gpt_api(
        self, prompt: str, output_format: dict, model: str
    ) -> dict:
        content = [{"type": "input_text", "text": prompt}]

        async def _call():
            response = await self.client.responses.create(
                model=model,
                input=[{"role": "user", "content": content}],
                text=output_format,
            )
            return json.loads(response.output_text) or {}

        return await retry_async(
            _call,
            max_retries=self.MAX_RETRIES,
            base_delay=2.0,  # OpenAI Rate Limit 대비 더 긴 대기
            retry_on=RETRY_EXCEPTIONS,
        )

타임아웃 설정 권장값

API 용도 권장 타임아웃 재시도 횟수 재시도 간격
ChatGPT 가사 생성 60초 3회 2초 → 4초 → 8초
Suno 노래 생성 요청 30초 3회 1초 → 2초 → 4초
Suno 상태 조회 30초 2회 1초 → 2초
Suno 타임스탬프 120초 2회 2초 → 4초
Creatomate 템플릿 조회 30초 2회 1초 → 2초
Creatomate 렌더링 요청 60초 3회 1초 → 2초 → 4초
Creatomate 상태 조회 30초 2회 1초 → 2초

구현 순서

  1. 1단계: retry.py 유틸리티 생성

    • 재사용 가능한 재시도 로직 구현
    • 단위 테스트 작성
  2. 2단계: ChatGPT 타임아웃 설정

    • 가장 시급한 문제 (현재 600초 기본값)
    • 타임아웃 + 재시도 동시 적용
  3. 3단계: Suno API 재시도 적용

    • generate(), get_task_status(), get_lyric_timestamp()
  4. 4단계: Creatomate API 재시도 적용

    • _request() 메서드 수정으로 전체 적용

테스트 체크리스트

각 수정 후 확인 사항:

  • 정상 요청 시 기존과 동일하게 동작
  • 타임아웃 발생 시 지정된 시간 내 예외 발생
  • 일시적 오류 시 재시도 후 성공
  • 모든 재시도 실패 시 적절한 에러 메시지 반환
  • 로그에 재시도 시도 기록 확인

롤백 계획

문제 발생 시:

  1. retry.py 사용 코드 제거 (기존 직접 호출로 복구)
  2. ChatGPT 타임아웃 설정 제거 (SDK 기본값으로 복구)

참고 사항

  • OpenAI SDK는 내부적으로 일부 재시도 로직이 있으나, 커스텀 제어가 제한적
  • httpx의 TimeoutExceptionConnectTimeout, ReadTimeout, WriteTimeout, PoolTimeout을 포함
  • Rate Limit 에러(429)는 재시도 시 더 긴 대기 시간 필요 (Retry-After 헤더 참고)