409 lines
11 KiB
Markdown
409 lines
11 KiB
Markdown
# 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`
|
|
|
|
**현재 코드:**
|
|
```python
|
|
class ChatgptService:
|
|
def __init__(self):
|
|
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
|
```
|
|
|
|
**수정 코드:**
|
|
```python
|
|
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 추가:**
|
|
```python
|
|
import httpx
|
|
```
|
|
|
|
---
|
|
|
|
### 2. 재시도 유틸리티 함수 생성
|
|
|
|
**파일:** `app/utils/retry.py` (새 파일)
|
|
|
|
```python
|
|
"""
|
|
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 메서드):**
|
|
|
|
```python
|
|
# 상단 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 호출의 기반)
|
|
|
|
**수정 코드:**
|
|
|
|
```python
|
|
# 상단 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`
|
|
|
|
**수정 코드:**
|
|
|
|
```python
|
|
# 상단 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의 `TimeoutException`은 `ConnectTimeout`, `ReadTimeout`, `WriteTimeout`, `PoolTimeout`을 포함
|
|
- Rate Limit 에러(429)는 재시도 시 더 긴 대기 시간 필요 (Retry-After 헤더 참고)
|