"""카페24 Admin API 전송 계층. 라우터/서비스는 httpx 를 직접 쓰지 않고 이 클라이언트만 쓴다. 여기서 처리하는 것: - Authorization 헤더 부착 (TokenService 가 만료 시 자동 갱신) - X-Cafe24-Api-Version 헤더 - timeout - 401 → 토큰 1회 강제 갱신 후 재시도 - 429 → Retry-After 존중, 제한 횟수만큼 대기 후 재시도 - 5xx / 네트워크 오류 → 지수 백오프 재시도 - 호출당 최소 간격 유지(대량 작업이 한 번에 몰리지 않게) - API 로그 기록 (토큰/시크릿은 절대 기록하지 않음) 동기(sync) 클라이언트다. 예약 worker 가 평범한 스크립트이고, 라우터에서는 `async def` 대신 `def` 핸들러로 선언해 FastAPI 의 스레드풀에서 실행하면 이벤트 루프를 막지 않는다. """ from __future__ import annotations import logging import threading import time from typing import Any, Callable import httpx from .config import Cafe24Config from .errors import ( Cafe24ApiError, Cafe24AuthError, Cafe24ConfigError, Cafe24RateLimitError, ) from .tokens import TokenService logger = logging.getLogger("cafe24.client") DEFAULT_TIMEOUT = 30.0 DEFAULT_MAX_RETRIES = 3 # 카페24 호출 사이 최소 간격(초). 대량 수정 시 429 를 미리 피한다. DEFAULT_MIN_INTERVAL = 0.35 # api_logger(endpoint, method, product_no, http_status, result, error_message, duration_ms) ApiLogger = Callable[..., None] class Cafe24Client: def __init__( self, config: Cafe24Config, token_service: TokenService, *, api_logger: ApiLogger | None = None, timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, min_interval: float = DEFAULT_MIN_INTERVAL, ): self._config = config self._tokens = token_service self._api_logger = api_logger self._timeout = timeout self._max_retries = max_retries self._min_interval = min_interval self._pace_lock = threading.Lock() self._last_call = 0.0 # ──────────────────────────────────────────────────────────── # 내부 헬퍼 # ──────────────────────────────────────────────────────────── def _pace(self) -> None: """호출 간 최소 간격 확보 (스레드 안전).""" with self._pace_lock: gap = time.monotonic() - self._last_call if gap < self._min_interval: time.sleep(self._min_interval - gap) self._last_call = time.monotonic() def _headers(self, access_token: str) -> dict[str, str]: return { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", "X-Cafe24-Api-Version": self._config.api_version, } def _log( self, *, endpoint: str, method: str, product_no: int | None, http_status: int | None, result: str, error_message: str, duration_ms: int, ) -> None: if self._api_logger is None: return try: self._api_logger( endpoint=endpoint, method=method, product_no=product_no, http_status=http_status, result=result, error_message=error_message[:500], duration_ms=duration_ms, ) except Exception: # noqa: BLE001 — 로그 실패가 본 작업을 막으면 안 된다. logger.exception("카페24 API 로그 기록 실패") @staticmethod def _error_message(response: httpx.Response) -> str: """카페24 오류 응답에서 사람이 읽을 메시지만 뽑는다.""" try: payload = response.json() except ValueError: return response.text[:500] error = payload.get("error") if isinstance(error, dict): parts = [str(error.get("message") or "")] detail = error.get("details") if isinstance(detail, list) and detail: parts.append("; ".join(str(d.get("message", d)) for d in detail[:3])) message = " / ".join(p for p in parts if p) if message: return message[:500] return str(payload)[:500] @staticmethod def _retry_after(response: httpx.Response, *, attempt: int) -> float: raw = (response.headers.get("Retry-After") or "").strip() if raw: try: return max(0.5, float(raw)) except ValueError: pass return min(8.0, 0.5 * (2**attempt)) # ──────────────────────────────────────────────────────────── # 공개 API # ──────────────────────────────────────────────────────────── def request( self, method: str, path: str, *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, product_no: int | None = None, timeout: float | None = None, ) -> dict[str, Any]: """카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환. timeout 은 호출별 초과 지정(이미지 업로드처럼 본문이 큰 요청용). 없으면 기본값. """ if not self._config.configured: raise Cafe24ConfigError( "카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing) ) endpoint = path if path.startswith("/") else f"/{path}" url = f"{self._config.api_base}{endpoint}" method = method.upper() forced_refresh = False last_error: Exception | None = None for attempt in range(self._max_retries + 1): self._pace() started = time.monotonic() status: int | None = None try: access_token = self._tokens.get_access_token() with httpx.Client(timeout=timeout or self._timeout) as client: response = client.request( method, url, headers=self._headers(access_token), params=params, json=json, ) status = response.status_code elapsed = int((time.monotonic() - started) * 1000) if 200 <= status < 300: self._log( endpoint=endpoint, method=method, product_no=product_no, http_status=status, result="SUCCESS", error_message="", duration_ms=elapsed, ) try: return response.json() except ValueError: return {} message = self._error_message(response) self._log( endpoint=endpoint, method=method, product_no=product_no, http_status=status, result="FAIL", error_message=message, duration_ms=elapsed, ) if status == 401 and not forced_refresh: # 서버가 토큰을 먼저 무효화한 경우 — 1회만 강제 갱신 후 재시도. forced_refresh = True self._tokens.force_expire() last_error = Cafe24AuthError("카페24 인증이 만료되어 갱신 후 재시도합니다.") continue if status == 401: raise Cafe24AuthError( "카페24 인증에 실패했습니다. 시스템 → 카페24 연결에서 재인증하세요.", needs_reauth=True, ) if status == 429: wait = self._retry_after(response, attempt=attempt) last_error = Cafe24RateLimitError( f"카페24 API 호출 제한(429). {wait:.1f}초 후 재시도합니다.", retry_after=wait, ) if attempt >= self._max_retries: raise last_error time.sleep(wait) continue error = Cafe24ApiError(message, status=status, endpoint=endpoint) if error.retryable and attempt < self._max_retries: last_error = error time.sleep(min(8.0, 0.5 * (2**attempt))) continue raise error except (Cafe24AuthError, Cafe24RateLimitError, Cafe24ApiError, Cafe24ConfigError): raise except httpx.HTTPError as exc: elapsed = int((time.monotonic() - started) * 1000) message = f"네트워크 오류 ({type(exc).__name__})" self._log( endpoint=endpoint, method=method, product_no=product_no, http_status=status, result="ERROR", error_message=message, duration_ms=elapsed, ) last_error = Cafe24ApiError(message, status=0, endpoint=endpoint) if attempt < self._max_retries: time.sleep(min(8.0, 0.5 * (2**attempt))) continue raise last_error from None # 재시도를 모두 소진 (401 강제갱신 루프 포함) if last_error: raise last_error raise Cafe24ApiError("카페24 API 호출에 실패했습니다.", endpoint=endpoint) def get(self, path: str, **kwargs: Any) -> dict[str, Any]: return self.request("GET", path, **kwargs) def put(self, path: str, **kwargs: Any) -> dict[str, Any]: return self.request("PUT", path, **kwargs) def post(self, path: str, **kwargs: Any) -> dict[str, Any]: return self.request("POST", path, **kwargs) def delete(self, path: str, **kwargs: Any) -> dict[str, Any]: return self.request("DELETE", path, **kwargs)