finish api exception handling
parent
1665d11d66
commit
e30e7304df
|
|
@ -64,7 +64,8 @@ KOREAN_CITIES = [
|
||||||
]
|
]
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
router = APIRouter(tags=["Home"])
|
# router = APIRouter(tags=["Home"])
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _extract_region_from_address(road_address: str | None) -> str:
|
def _extract_region_from_address(road_address: str | None) -> str:
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,29 @@ from typing import Literal
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
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")
|
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 타입 정의
|
# Orientation 타입 정의
|
||||||
OrientationType = Literal["horizontal", "vertical"]
|
OrientationType = Literal["horizontal", "vertical"]
|
||||||
|
|
||||||
|
|
@ -135,7 +152,10 @@ async def get_shared_client() -> httpx.AsyncClient:
|
||||||
global _shared_client
|
global _shared_client
|
||||||
if _shared_client is None or _shared_client.is_closed:
|
if _shared_client is None or _shared_client.is_closed:
|
||||||
_shared_client = httpx.AsyncClient(
|
_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),
|
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||||
)
|
)
|
||||||
return _shared_client
|
return _shared_client
|
||||||
|
|
@ -217,7 +237,7 @@ class CreatomateService:
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
url: str,
|
url: str,
|
||||||
timeout: float = 30.0,
|
timeout: float | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
"""HTTP 요청을 수행합니다.
|
"""HTTP 요청을 수행합니다.
|
||||||
|
|
@ -225,7 +245,7 @@ class CreatomateService:
|
||||||
Args:
|
Args:
|
||||||
method: HTTP 메서드 ("GET", "POST", etc.)
|
method: HTTP 메서드 ("GET", "POST", etc.)
|
||||||
url: 요청 URL
|
url: 요청 URL
|
||||||
timeout: 요청 타임아웃 (초)
|
timeout: 요청 타임아웃 (초). None이면 기본값 사용
|
||||||
**kwargs: httpx 요청에 전달할 추가 인자
|
**kwargs: httpx 요청에 전달할 추가 인자
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -236,15 +256,18 @@ class CreatomateService:
|
||||||
"""
|
"""
|
||||||
logger.info(f"[Creatomate] {method} {url}")
|
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()
|
client = await get_shared_client()
|
||||||
|
|
||||||
if method.upper() == "GET":
|
if method.upper() == "GET":
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
url, headers=self.headers, timeout=timeout, **kwargs
|
url, headers=self.headers, timeout=actual_timeout, **kwargs
|
||||||
)
|
)
|
||||||
elif method.upper() == "POST":
|
elif method.upper() == "POST":
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
url, headers=self.headers, timeout=timeout, **kwargs
|
url, headers=self.headers, timeout=actual_timeout, **kwargs
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||||
|
|
@ -255,7 +278,7 @@ class CreatomateService:
|
||||||
async def get_all_templates_data(self) -> dict:
|
async def get_all_templates_data(self) -> dict:
|
||||||
"""모든 템플릿 정보를 조회합니다."""
|
"""모든 템플릿 정보를 조회합니다."""
|
||||||
url = f"{self.BASE_URL}/v1/templates"
|
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()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
@ -288,7 +311,7 @@ class CreatomateService:
|
||||||
|
|
||||||
# API 호출
|
# API 호출
|
||||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
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()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -433,31 +456,148 @@ class CreatomateService:
|
||||||
async def make_creatomate_call(
|
async def make_creatomate_call(
|
||||||
self, template_id: str, modifications: dict
|
self, template_id: str, modifications: dict
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Creatomate에 렌더링 요청을 보냅니다.
|
"""Creatomate에 렌더링 요청을 보냅니다 (재시도 로직 포함).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_id: Creatomate 템플릿 ID
|
||||||
|
modifications: 수정사항 딕셔너리
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Creatomate API 응답 데이터
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CreatomateResponseError: API 오류 또는 재시도 실패 시
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
response에 요청 정보가 있으니 폴링 필요
|
response에 요청 정보가 있으니 폴링 필요
|
||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/v2/renders"
|
url = f"{self.BASE_URL}/v2/renders"
|
||||||
data = {
|
payload = {
|
||||||
"template_id": template_id,
|
"template_id": template_id,
|
||||||
"modifications": modifications,
|
"modifications": modifications,
|
||||||
}
|
}
|
||||||
response = await self._request("POST", url, timeout=60.0, json=data)
|
|
||||||
response.raise_for_status()
|
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()
|
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:
|
async def make_creatomate_custom_call(self, source: dict) -> dict:
|
||||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다 (재시도 로직 포함).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: 렌더링 소스 딕셔너리
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Creatomate API 응답 데이터
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CreatomateResponseError: API 오류 또는 재시도 실패 시
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
response에 요청 정보가 있으니 폴링 필요
|
response에 요청 정보가 있으니 폴링 필요
|
||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/v2/renders"
|
url = f"{self.BASE_URL}/v2/renders"
|
||||||
response = await self._request("POST", url, timeout=60.0, json=source)
|
|
||||||
response.raise_for_status()
|
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()
|
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)
|
# 하위 호환성을 위한 별칭 (deprecated)
|
||||||
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
||||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||||
|
|
@ -485,7 +625,7 @@ class CreatomateService:
|
||||||
- failed: 실패
|
- failed: 실패
|
||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
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()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,28 @@ from typing import Any, List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import apikey_settings
|
|
||||||
from app.song.schemas.song_schema import PollingSongResponse, SongClipData
|
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:
|
class SunoService:
|
||||||
|
|
@ -122,35 +142,75 @@ class SunoService:
|
||||||
if genre:
|
if genre:
|
||||||
payload["style"] = genre
|
payload["style"] = genre
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(recovery_settings.SUNO_MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.BASE_URL}/generate",
|
f"{self.BASE_URL}/generate",
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=30.0,
|
timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# 응답: {"code": 200, "msg": "success", "data": {"taskId": "..."}}
|
|
||||||
# API 응답 검증
|
# API 응답 검증
|
||||||
if data is None:
|
if data is None:
|
||||||
raise ValueError("Suno API returned empty response")
|
raise SunoResponseError("Suno API returned empty response")
|
||||||
|
|
||||||
if data.get("code") != 200:
|
if data.get("code") != 200:
|
||||||
error_msg = data.get("msg", "Unknown error")
|
error_msg = data.get("msg", "Unknown error")
|
||||||
raise ValueError(f"Suno API error: {error_msg}")
|
raise SunoResponseError(f"Suno API error: {error_msg}", original_response=data)
|
||||||
|
|
||||||
response_data = data.get("data")
|
response_data = data.get("data")
|
||||||
if response_data is None:
|
if response_data is None:
|
||||||
raise ValueError(f"Suno API response missing 'data' field: {data}")
|
raise SunoResponseError(f"Suno API response missing 'data' field", original_response=data)
|
||||||
|
|
||||||
task_id = response_data.get("taskId")
|
task_id = response_data.get("taskId")
|
||||||
if task_id is None:
|
if task_id is None:
|
||||||
raise ValueError(f"Suno API response missing 'taskId': {response_data}")
|
raise SunoResponseError(f"Suno API response missing 'taskId'", original_response=response_data)
|
||||||
|
|
||||||
return task_id
|
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]:
|
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",
|
f"{self.BASE_URL}/generate/record-info",
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
params={"taskId": task_id},
|
params={"taskId": task_id},
|
||||||
timeout=30.0,
|
timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
@ -198,7 +258,7 @@ class SunoService:
|
||||||
f"{self.BASE_URL}/generate/get-timestamped-lyrics",
|
f"{self.BASE_URL}/generate/get-timestamped-lyrics",
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=120.0,
|
timeout=recovery_settings.SUNO_LYRIC_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
|
||||||
44
config.py
44
config.py
|
|
@ -155,8 +155,14 @@ class PromptSettings(BaseSettings):
|
||||||
|
|
||||||
|
|
||||||
class RecoverySettings(BaseSettings):
|
class RecoverySettings(BaseSettings):
|
||||||
"""ChatGPT API 복구 및 타임아웃 설정"""
|
"""외부 API 복구 및 타임아웃 설정
|
||||||
|
|
||||||
|
ChatGPT, Suno, Creatomate API의 타임아웃 및 재시도 설정을 관리합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ChatGPT API 설정
|
||||||
|
# ============================================================
|
||||||
CHATGPT_TIMEOUT: float = Field(
|
CHATGPT_TIMEOUT: float = Field(
|
||||||
default=600.0,
|
default=600.0,
|
||||||
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
|
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
|
||||||
|
|
@ -166,6 +172,42 @@ class RecoverySettings(BaseSettings):
|
||||||
description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
|
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
|
model_config = _base_config
|
||||||
|
|
||||||
class KakaoSettings(BaseSettings):
|
class KakaoSettings(BaseSettings):
|
||||||
|
|
|
||||||
661
error_plan.md
661
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 상태 업데이트 | 응답 상태 변수 |
|
### ❌ 미구현 항목
|
||||||
|------|------|-----------------|----------------|
|
| 항목 | Suno API | Creatomate API |
|
||||||
| `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"` 설정 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 개선 목표
|
## 1. RecoverySettings에 Suno/Creatomate 설정 추가
|
||||||
|
|
||||||
1. **DB 상태 업데이트**: 에러 발생 시 DB에 `status = "failed"` 저장
|
### 파일: `config.py`
|
||||||
2. **클라이언트 응답**: 기존 상태 변수가 있으면 `"failed"` 설정, 없으면 변수 추가 후 설정
|
|
||||||
|
|
||||||
---
|
**변경 전:**
|
||||||
|
|
||||||
## 3. 상세 작업 계획
|
|
||||||
|
|
||||||
### 3.1 lyric_task.py - `ChatGPTResponseError` 명시적 처리
|
|
||||||
|
|
||||||
**파일**: `app/lyric/worker/lyric_task.py`
|
|
||||||
|
|
||||||
**현재 코드 (Line 138-141)**:
|
|
||||||
```python
|
```python
|
||||||
except Exception as e:
|
class RecoverySettings(BaseSettings):
|
||||||
elapsed = (time.perf_counter() - task_start) * 1000
|
"""ChatGPT API 복구 및 타임아웃 설정"""
|
||||||
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)}")
|
|
||||||
```
|
|
||||||
|
|
||||||
**변경 코드**:
|
CHATGPT_TIMEOUT: float = Field(
|
||||||
```python
|
default=600.0,
|
||||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
|
||||||
|
|
||||||
# ... 기존 코드 ...
|
|
||||||
|
|
||||||
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)"
|
|
||||||
)
|
)
|
||||||
await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}")
|
CHATGPT_MAX_RETRIES: int = Field(
|
||||||
|
default=1,
|
||||||
except SQLAlchemyError as e:
|
description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
|
||||||
# ... 기존 코드 유지 ...
|
|
||||||
|
|
||||||
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)"
|
|
||||||
)
|
)
|
||||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
|
||||||
image_count: int = Field(..., description="이미지 개수")
|
model_config = _base_config
|
||||||
processed_info: Optional[ProcessedInfo] = Field(None, ...)
|
```
|
||||||
marketing_analysis: Optional[MarketingAnalysis] = Field(None, ...)
|
|
||||||
|
**변경 후:**
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.3 home.py - 크롤링 엔드포인트 에러 처리
|
## 2. Suno API 커스텀 예외 클래스 추가
|
||||||
|
|
||||||
**파일**: `app/home/api/routers/v1/home.py`
|
### 파일: `app/utils/suno.py`
|
||||||
|
|
||||||
**현재 코드 (Line 296-303)**:
|
**변경 전 (라인 1-20):**
|
||||||
```python
|
```python
|
||||||
except Exception as e:
|
import httpx
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
import json
|
||||||
logger.error(...)
|
from typing import Optional
|
||||||
marketing_analysis = None
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("suno")
|
||||||
```
|
```
|
||||||
|
|
||||||
**변경 코드**:
|
**변경 후:**
|
||||||
```python
|
```python
|
||||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
import httpx
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
# ... 기존 코드 ...
|
from app.utils.logger import get_logger
|
||||||
|
from config import recovery_settings
|
||||||
|
|
||||||
except ChatGPTResponseError as e:
|
logger = get_logger("suno")
|
||||||
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)"
|
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,
|
||||||
)
|
)
|
||||||
marketing_analysis = None
|
```
|
||||||
gpt_status = "failed"
|
|
||||||
|
|
||||||
except Exception as e:
|
**변경 후:**
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
```python
|
||||||
logger.error(...)
|
async with httpx.AsyncClient() as client:
|
||||||
marketing_analysis = None
|
response = await client.post(
|
||||||
gpt_status = "failed"
|
f"{self.base_url}/generate",
|
||||||
|
headers=self.headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
# 응답 반환 부분 수정
|
**변경 전 (라인 173):**
|
||||||
return {
|
```python
|
||||||
"status": gpt_status if 'gpt_status' in locals() else "completed",
|
timeout=30.0,
|
||||||
"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,
|
```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)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 파일 변경 요약
|
## 6. Creatomate API 타임아웃 적용
|
||||||
|
|
||||||
| 파일 | 변경 내용 |
|
### 파일: `app/utils/creatomate.py`
|
||||||
|------|----------|
|
|
||||||
| `app/lyric/worker/lyric_task.py` | `ChatGPTResponseError` import 및 명시적 처리 추가 |
|
**변경 전 (라인 138):**
|
||||||
| `app/home/schemas/home_schema.py` | `CrawlingResponse`에 `status` 필드 추가 |
|
```python
|
||||||
| `app/home/api/routers/v1/home.py` | `ChatGPTResponseError` 처리, 응답에 `status` 포함 |
|
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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 변경 후 동작
|
## 7. Creatomate API 재시도 로직 추가
|
||||||
|
|
||||||
### 5.1 lyric_task.py (가사 생성)
|
### 파일: `app/utils/creatomate.py`
|
||||||
|
|
||||||
| 상황 | DB status | DB lyric_result |
|
**변경 전 - render_with_json() 메서드 (라인 440~):**
|
||||||
|------|-----------|-----------------|
|
```python
|
||||||
| 성공 | `"completed"` | 생성된 가사 |
|
async def render_with_json(
|
||||||
| ChatGPT 에러 | `"failed"` | `"ChatGPT Error: {message}"` |
|
self,
|
||||||
| DB 에러 | `"failed"` | `"Database Error: {message}"` |
|
template_id: str,
|
||||||
| 기타 에러 | `"failed"` | `"Error: {message}"` |
|
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}",
|
||||||
|
}
|
||||||
|
|
||||||
### 5.2 home.py (크롤링)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
| 상황 | 응답 status | marketing_analysis |
|
if response.status_code != 200:
|
||||||
|------|------------|-------------------|
|
logger.error(f"Failed to render: {response.text}")
|
||||||
| 성공 | `"completed"` | 분석 결과 |
|
raise Exception(f"Failed to render: {response.status_code}")
|
||||||
| ChatGPT 에러 | `"failed"` | `null` |
|
|
||||||
| 기타 에러 | `"failed"` | `null` |
|
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)}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 구현 순서
|
## 작업 체크리스트
|
||||||
|
|
||||||
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` 설정
|
| 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`에서 통합 관리합니다.
|
||||||
|
|
|
||||||
8
main.py
8
main.py
|
|
@ -38,10 +38,10 @@ tags_metadata = [
|
||||||
- **Refresh Token**: 7일 유효, Access Token 갱신 시 사용
|
- **Refresh Token**: 7일 유효, Access Token 갱신 시 사용
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
{
|
# {
|
||||||
"name": "Home",
|
# "name": "Home",
|
||||||
"description": "홈 화면 및 프로젝트 관리 API",
|
# "description": "홈 화면 및 프로젝트 관리 API",
|
||||||
},
|
# },
|
||||||
{
|
{
|
||||||
"name": "Crawling",
|
"name": "Crawling",
|
||||||
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
|
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue