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