import logging import time from typing import Any import requests from django.conf import settings from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError logger = logging.getLogger(__name__) class BalldontlieClient: """HTTP client for balldontlie with timeout/retry/rate-limit handling.""" def __init__(self, session: requests.Session | None = None): self.base_url = settings.PROVIDER_BALLDONTLIE_BASE_URL.rstrip("/") self.api_key = settings.PROVIDER_BALLDONTLIE_API_KEY self.timeout_seconds = settings.PROVIDER_HTTP_TIMEOUT_SECONDS self.max_retries = settings.PROVIDER_REQUEST_RETRIES self.retry_sleep_seconds = settings.PROVIDER_REQUEST_RETRY_SLEEP self.session = session or requests.Session() def _headers(self) -> dict[str, str]: headers = {"Accept": "application/json"} if self.api_key: headers["Authorization"] = self.api_key return headers def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]: url = f"{self.base_url}/{path.lstrip('/')}" for attempt in range(1, self.max_retries + 1): try: response = self.session.get( url, params=params, headers=self._headers(), timeout=self.timeout_seconds, ) except requests.Timeout as exc: logger.warning( "provider_http_timeout", extra={"provider": "balldontlie", "url": url, "attempt": attempt}, ) if attempt >= self.max_retries: raise ProviderTransientError(f"Timeout calling balldontlie: {url}") from exc time.sleep(self.retry_sleep_seconds * attempt) continue except requests.RequestException as exc: logger.warning( "provider_http_error", extra={"provider": "balldontlie", "url": url, "attempt": attempt}, ) if attempt >= self.max_retries: raise ProviderTransientError(f"Network error calling balldontlie: {url}") from exc time.sleep(self.retry_sleep_seconds * attempt) continue status = response.status_code if status == 429: retry_after = int(response.headers.get("Retry-After", "30") or "30") logger.warning( "provider_rate_limited", extra={ "provider": "balldontlie", "url": url, "attempt": attempt, "retry_after": retry_after, }, ) if attempt >= self.max_retries: raise ProviderRateLimitError( "balldontlie rate limit reached", retry_after_seconds=retry_after, ) time.sleep(max(retry_after, self.retry_sleep_seconds * attempt)) continue if status >= 500: logger.warning( "provider_server_error", extra={"provider": "balldontlie", "url": url, "attempt": attempt, "status": status}, ) if attempt >= self.max_retries: raise ProviderTransientError(f"balldontlie server error: {status}") time.sleep(self.retry_sleep_seconds * attempt) continue if status >= 400: body_preview = response.text[:240] raise ProviderTransientError( f"balldontlie client error status={status} path={path} body={body_preview}" ) try: return response.json() except ValueError as exc: raise ProviderTransientError(f"Invalid JSON from balldontlie for {path}") from exc raise ProviderTransientError(f"Failed to call balldontlie path={path}") def list_paginated( self, path: str, *, params: dict[str, Any] | None = None, per_page: int = 100, page_limit: int = 1, ) -> list[dict[str, Any]]: page = 1 rows: list[dict[str, Any]] = [] query = dict(params or {}) while page <= page_limit: query.update({"page": page, "per_page": per_page}) payload = self.get_json(path, params=query) data = payload.get("data") or [] if isinstance(data, list): rows.extend(data) meta = payload.get("meta") or {} next_page = meta.get("next_page") if not next_page: break page = int(next_page) return rows