Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import random | |
| from dataclasses import dataclass | |
| from typing import Any, Optional | |
| import httpx | |
| from .utils import ApiMetrics, RateLimiter | |
| BASE_URL = "https://open.api.nexon.com" | |
| class ApiError(RuntimeError): | |
| def __init__(self, status_code: int, message: str, payload: Any = None) -> None: | |
| super().__init__(message) | |
| self.status_code = status_code | |
| self.payload = payload | |
| class RateLimitError(ApiError): | |
| def __init__(self, status_code: int, message: str, payload: Any = None, retry_after: Optional[float] = None) -> None: | |
| super().__init__(status_code, message, payload) | |
| self.retry_after = retry_after | |
| class ServerError(ApiError): | |
| pass | |
| class DataPreparingError(ApiError): | |
| pass | |
| class TransportError(RuntimeError): | |
| pass | |
| class ApiClient: | |
| api_key: str | |
| concurrency: int = 8 | |
| rps: float = 500.0 | |
| timeout_seconds: float = 30.0 | |
| max_attempts: int = 5 | |
| def __post_init__(self) -> None: | |
| self._client: Optional[httpx.AsyncClient] = None | |
| self._semaphore = asyncio.Semaphore(self.concurrency) | |
| self._rate_limiter = RateLimiter(self.rps) | |
| self.metrics = ApiMetrics() | |
| async def __aenter__(self) -> "ApiClient": | |
| headers = {"x-nxopen-api-key": self.api_key} | |
| self._client = httpx.AsyncClient(base_url=BASE_URL, headers=headers) | |
| return self | |
| async def __aexit__(self, exc_type, exc, tb) -> None: | |
| if self._client: | |
| await self._client.aclose() | |
| async def get(self, path: str, params: dict[str, Any]) -> dict[str, Any]: | |
| return await self._request_json("GET", path, params=params) | |
| async def _request_json(self, method: str, path: str, params: dict[str, Any]) -> dict[str, Any]: | |
| attempt = 0 | |
| while True: | |
| attempt += 1 | |
| try: | |
| await self._rate_limiter.acquire() | |
| async with self._semaphore: | |
| assert self._client is not None | |
| response = await self._client.request( | |
| method, | |
| path, | |
| params=params, | |
| timeout=self.timeout_seconds, | |
| ) | |
| self.metrics.total_requests += 1 | |
| if 200 <= response.status_code < 300: | |
| return response.json() | |
| payload = _safe_json(response) | |
| message = _extract_message(payload) | |
| if response.status_code == 400 and _is_data_preparing(payload): | |
| self.metrics.data_preparing_hits += 1 | |
| raise DataPreparingError(response.status_code, message, payload) | |
| if response.status_code == 429: | |
| self.metrics.rate_limit_hits += 1 | |
| retry_after = _retry_after_seconds(response) | |
| raise RateLimitError(response.status_code, message, payload, retry_after=retry_after) | |
| if response.status_code >= 500: | |
| self.metrics.server_errors += 1 | |
| raise ServerError(response.status_code, message, payload) | |
| self.metrics.other_errors += 1 | |
| raise ApiError(response.status_code, message, payload) | |
| except (httpx.TimeoutException, httpx.TransportError) as exc: | |
| self.metrics.other_errors += 1 | |
| error = TransportError(str(exc)) | |
| except (RateLimitError, ServerError, DataPreparingError) as exc: | |
| error = exc | |
| except ApiError: | |
| raise | |
| if attempt >= self.max_attempts: | |
| raise error | |
| await asyncio.sleep(_compute_wait_seconds(error, attempt)) | |
| def _safe_json(response: httpx.Response) -> Any: | |
| try: | |
| return response.json() | |
| except json.JSONDecodeError: | |
| return {"message": response.text} | |
| def _extract_message(payload: Any) -> str: | |
| if isinstance(payload, dict): | |
| if isinstance(payload.get("error"), dict): | |
| return payload["error"].get("message") or "API error" | |
| return payload.get("message") or "API error" | |
| return "API error" | |
| def _extract_code(payload: Any) -> Optional[str]: | |
| if isinstance(payload, dict): | |
| if isinstance(payload.get("error"), dict): | |
| return payload["error"].get("code") or payload["error"].get("error_code") | |
| return payload.get("code") or payload.get("error_code") | |
| return None | |
| def _is_data_preparing(payload: Any) -> bool: | |
| code = _extract_code(payload) | |
| message = _extract_message(payload) | |
| if code and code.upper() == "OPENAPI00009": | |
| return True | |
| if message and "Data being prepared" in message: | |
| return True | |
| return False | |
| def _retry_after_seconds(response: httpx.Response) -> Optional[float]: | |
| value = response.headers.get("Retry-After") | |
| if not value: | |
| return None | |
| try: | |
| return float(value) | |
| except ValueError: | |
| return None | |
| def _compute_wait_seconds(error: Exception, attempt: int) -> float: | |
| if isinstance(error, DataPreparingError): | |
| return random.uniform(30, 120) | |
| base = 1.0 | |
| max_wait = 30.0 | |
| wait = min(max_wait, base * (2 ** (attempt - 1))) | |
| jitter = random.uniform(0, 0.5) | |
| if isinstance(error, RateLimitError) and error.retry_after: | |
| return max(wait + jitter, error.retry_after) | |
| return wait + jitter | |