"""NAVER API HUB 공통 호출 계층 — 인증 헤더·요청·오류 해석을 한곳에 모은다. 옛 오픈API(openapi.naver.com) 와 달라진 점: 엔드포인트 https://naverapihub.apigw.ntruss.com 인증 헤더 X-NCP-APIGW-API-KEY-ID / X-NCP-APIGW-API-KEY (NCP 콘솔 발급 Client ID/Secret) 경로 규칙 /search/v1/{type} · /shopping/v1/{...} (옛 /v1/search/{type}.json 과 순서가 반대) 오류 바디가 계층마다 다르다(공식 문서 'NAVER API HUB 개요' 기준). 호출부가 세 형식을 다 알 필요는 없으므로 _parse_error 가 하나의 NaverApiHubError 로 정규화한다: 1) 게이트웨이(인증 실패·라우팅 실패) {"error": {"errorCode", "message", "details"}} 2) Search API 파라미터 검증 {"errorCode", "errorMessage"} 3) 검색어 트렌드·쇼핑 인사이트 검증 {"errMsg", "errId"} """ import json from typing import Any, Optional import httpx from common.logger import LOG from config.config_models import NaverApiHubConfig from config.server_configs import naver_api_hub_config class NaverApiHubError(Exception): """허브 호출 실패. status/code 로 재시도 가치를 구분한다. auth_failed(401·403) 와 not_found(404) 는 재시도해도 그대로다 — 키 설정이나 경로가 틀린 것. 쿼터 소진(429)·5xx 만 재시도 가치가 있다(retryable). """ def __init__(self, message: str, *, status: int, code: Optional[str] = None, path: str = ""): super().__init__(message) self.status = status self.code = code self.path = path @property def auth_failed(self) -> bool: return self.status in (401, 403) @property def not_found(self) -> bool: return self.status == 404 @property def retryable(self) -> bool: return self.status == 429 or self.status >= 500 class NaverApiHubClient: """허브 공통 클라이언트. 개별 API(쇼핑 인사이트 등)는 이 위에 얹는다. transport 는 테스트에서 httpx.MockTransport 를 꽂기 위한 주입점이다(네트워크 없이 계약 검증). """ def __init__(self, cfg: Optional[NaverApiHubConfig] = None, *, transport: Any = None): self._cfg = cfg if cfg is not None else naver_api_hub_config self._transport = transport @property def enabled(self) -> bool: return self._cfg.enabled def _headers(self) -> dict: return { "X-NCP-APIGW-API-KEY-ID": self._cfg.client_id, "X-NCP-APIGW-API-KEY": self._cfg.client_secret, "Content-Type": "application/json", } async def post(self, path: str, body: dict) -> dict: """POST 요청 1건. 성공이면 파싱된 JSON, 실패면 NaverApiHubError.""" if not self.enabled: raise NaverApiHubError( "NAVER API HUB 미설정 — config.local.toml [NaverApiHubConfig] 의 client_id/client_secret 을 채우세요", status=0, path=path, ) url = f"{self._cfg.base_url.rstrip('/')}{path}" async with httpx.AsyncClient(timeout=self._cfg.timeout_sec, transport=self._transport) as client: r = await client.post(url, headers=self._headers(), json=body) if r.status_code != 200: raise self._parse_error(r, path) return r.json() @staticmethod def _parse_error(r: httpx.Response, path: str) -> NaverApiHubError: """세 가지 오류 바디 형식을 하나로 정규화. JSON 이 아니면 본문 앞부분을 그대로 싣는다.""" code = None message = r.text[:200] try: body = r.json() except (json.JSONDecodeError, ValueError): body = None if isinstance(body, dict): if isinstance(body.get("error"), dict): # 1) 게이트웨이 err = body["error"] code = str(err.get("errorCode") or "") or None message = " ".join(str(v) for v in (err.get("message"), err.get("details")) if v) elif "errorMessage" in body or "errorCode" in body: # 2) Search API code = str(body.get("errorCode") or "") or None message = str(body.get("errorMessage") or message) elif "errMsg" in body or "errId" in body: # 3) 트렌드·인사이트 code = str(body.get("errId") or "") or None message = str(body.get("errMsg") or message) LOG.w(f"[naver-hub] {r.status_code} {path} code={code} {message}") return NaverApiHubError(message, status=r.status_code, code=code, path=path)