feat(naver-fetch): NAS realestate 클라이언트 (targets/ingest)
This commit is contained in:
48
services/naver-fetch/nas_client.py
Normal file
48
services/naver-fetch/nas_client.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""NAS realestate 내부 계약 — X-Internal-Key + retry."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MAX_ATTEMPTS = 3
|
||||||
|
_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||||
|
|
||||||
|
|
||||||
|
class NASClient:
|
||||||
|
def __init__(self, base_url: str, api_key: str, timeout: float = 15.0):
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._api_key = api_key
|
||||||
|
self._client = httpx.AsyncClient(timeout=timeout)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def get_targets(self) -> dict:
|
||||||
|
return await self._request("GET", "/api/internal/realestate/targets")
|
||||||
|
|
||||||
|
async def post_ingest(self, fetched_at: str, batches: list[dict]) -> dict:
|
||||||
|
return await self._request(
|
||||||
|
"POST", "/api/internal/realestate/listings-ingest",
|
||||||
|
json={"fetched_at": fetched_at, "batches": batches})
|
||||||
|
|
||||||
|
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||||
|
url = f"{self._base_url}{path}"
|
||||||
|
headers = {"X-Internal-Key": self._api_key}
|
||||||
|
for attempt in range(_MAX_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
resp = await self._client.request(method, url, headers=headers, **kwargs)
|
||||||
|
if resp.status_code in _RETRY_STATUSES and attempt < _MAX_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
if attempt < _MAX_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise RuntimeError("retry exhausted")
|
||||||
41
services/naver-fetch/tests/test_nas_client.py
Normal file
41
services/naver-fetch/tests/test_nas_client.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""NASClient — targets/ingest + X-Internal-Key (respx)."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from nas_client import NASClient
|
||||||
|
|
||||||
|
BASE = "http://nas.test"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_targets_sends_key():
|
||||||
|
route = respx.get(f"{BASE}/api/internal/realestate/targets").mock(
|
||||||
|
return_value=httpx.Response(200, json={
|
||||||
|
"dongs": [{"dong": "신대방동", "cortar_no": "1159010100"}],
|
||||||
|
"deal_types": ["전세", "매매"], "page_limit": 2}))
|
||||||
|
c = NASClient(BASE, "KEY")
|
||||||
|
tg = await c.get_targets()
|
||||||
|
assert tg["page_limit"] == 2
|
||||||
|
assert tg["dongs"][0]["cortar_no"] == "1159010100"
|
||||||
|
assert route.calls.last.request.headers["X-Internal-Key"] == "KEY"
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_post_ingest_payload():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _resp(request):
|
||||||
|
captured.update(_json.loads(request.content))
|
||||||
|
return httpx.Response(200, json={"received": 3, "new": 2, "matched": 1})
|
||||||
|
|
||||||
|
respx.post(f"{BASE}/api/internal/realestate/listings-ingest").mock(side_effect=_resp)
|
||||||
|
c = NASClient(BASE, "KEY")
|
||||||
|
batches = [{"dong": "신대방동", "articles": [{"articleNo": "1"}, {"articleNo": "2"}]}]
|
||||||
|
out = await c.post_ingest("2026-07-10T05:00:00Z", batches)
|
||||||
|
assert out == {"received": 3, "new": 2, "matched": 1}
|
||||||
|
assert captured["fetched_at"] == "2026-07-10T05:00:00Z"
|
||||||
|
assert captured["batches"] == batches
|
||||||
|
await c.close()
|
||||||
Reference in New Issue
Block a user