o2o-castad-backend/app/p2v/services/client.py
2026-08-26 13:52:18 +09:00

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