119 lines
4.0 KiB
Python
119 lines
4.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""P2V 서버 HTTP 클라이언트.
|
|
|
|
httpx.AsyncClient 를 모듈 싱글턴으로 유지한다 (creatomate.py 의 _shared_client 패턴).
|
|
모든 요청에 X-P2V-Key 를 실어 보내고, 연결 실패·업스트림 오류를 도메인 예외로 바꾼다.
|
|
"""
|
|
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from app.p2v.exceptions import P2vUnavailableError, P2vUpstreamError
|
|
from app.utils.logger import get_logger
|
|
from config import p2v_settings
|
|
|
|
logger = get_logger("p2v")
|
|
|
|
#: 기본 타임아웃. 잡 생성(파일 업로드)·레퍼런스 분석(동기 10초 안팎)을 견디도록
|
|
#: read 를 넉넉히 잡는다. 폴링 GET 은 서버가 즉시 답하므로 문제되지 않는다.
|
|
_DEFAULT_TIMEOUT = httpx.Timeout(10.0, read=120.0)
|
|
#: 결과물 다운로드용 (영상 수십 MB)
|
|
_DOWNLOAD_TIMEOUT = httpx.Timeout(10.0, read=300.0)
|
|
|
|
_client: Optional[httpx.AsyncClient] = None
|
|
|
|
|
|
def _headers() -> dict[str, str]:
|
|
key = (p2v_settings.P2V_ACCESS_KEY or "").strip()
|
|
return {"X-P2V-Key": key} if key else {}
|
|
|
|
|
|
def get_client() -> httpx.AsyncClient:
|
|
"""싱글턴 AsyncClient. 커넥션 풀을 프로세스에서 공유한다."""
|
|
global _client
|
|
if _client is None or _client.is_closed:
|
|
_client = httpx.AsyncClient(
|
|
base_url=p2v_settings.P2V_BASE_URL.rstrip("/"),
|
|
headers=_headers(),
|
|
timeout=_DEFAULT_TIMEOUT,
|
|
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
|
|
)
|
|
return _client
|
|
|
|
|
|
async def close_client() -> None:
|
|
"""앱 종료 시 커넥션 풀 정리 (lifespan shutdown 에서 호출)"""
|
|
global _client
|
|
if _client is not None and not _client.is_closed:
|
|
await _client.aclose()
|
|
_client = None
|
|
|
|
|
|
def _raise_for_status(resp: httpx.Response) -> None:
|
|
"""업스트림 4xx/5xx 를 도메인 예외로 변환한다.
|
|
|
|
- 401: castad 의 접근 키 문제다. 프론트에 401 을 흘리면 "castad 로그인 만료"로
|
|
오독되므로 503 으로 바꾼다.
|
|
- 그 외: P2V 의 detail 과 상태 코드를 그대로 중계한다 (조용한 fallback 금지).
|
|
"""
|
|
if resp.status_code < 400:
|
|
return
|
|
if resp.status_code == 401:
|
|
logger.error("[p2v] 접근 키 거부 — P2V_ACCESS_KEY 설정을 확인할 것")
|
|
raise P2vUnavailableError("접근 키가 유효하지 않습니다")
|
|
try:
|
|
detail = resp.json().get("detail", "")
|
|
except Exception:
|
|
detail = resp.text[:300]
|
|
raise P2vUpstreamError(str(detail) or f"P2V 오류 {resp.status_code}", resp.status_code)
|
|
|
|
|
|
async def request_json(
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
data: Optional[dict] = None,
|
|
files: Optional[dict] = None,
|
|
json_body: Optional[dict] = None,
|
|
) -> Any:
|
|
"""P2V API 호출 → JSON 응답.
|
|
|
|
Args:
|
|
method: HTTP 메서드
|
|
path: /api/... 경로
|
|
data: form 필드 (multipart 일 때 files 와 함께)
|
|
files: httpx files 딕셔너리 {"poster": (name, bytes, content_type)}
|
|
json_body: JSON 바디
|
|
|
|
Raises:
|
|
P2vUnavailableError: 연결 실패·타임아웃·접근 키 거부
|
|
P2vUpstreamError: P2V 가 4xx/5xx 를 반환
|
|
"""
|
|
try:
|
|
resp = await get_client().request(
|
|
method, path, data=data, files=files, json=json_body
|
|
)
|
|
except httpx.HTTPError as e:
|
|
logger.warning(f"[p2v] {method} {path} 연결 실패: {type(e).__name__}: {e}")
|
|
raise P2vUnavailableError(type(e).__name__)
|
|
_raise_for_status(resp)
|
|
if not resp.content:
|
|
return None
|
|
return resp.json()
|
|
|
|
|
|
async def download(path: str) -> tuple[bytes, str]:
|
|
"""P2V 정적 파일(/files/...)을 받아온다.
|
|
|
|
Returns:
|
|
(바이트, Content-Type)
|
|
"""
|
|
try:
|
|
resp = await get_client().get(path, timeout=_DOWNLOAD_TIMEOUT)
|
|
except httpx.HTTPError as e:
|
|
logger.warning(f"[p2v] GET {path} 다운로드 실패: {type(e).__name__}: {e}")
|
|
raise P2vUnavailableError(type(e).__name__)
|
|
_raise_for_status(resp)
|
|
return resp.content, resp.headers.get("content-type", "application/octet-stream")
|