import logging import time from typing import Any import requests from django.conf import settings from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError, ProviderUnauthorizedError 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] if status == 401: raise ProviderUnauthorizedError( provider="balldontlie", path=path, status_code=status, detail=body_preview, ) 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 cursor = None rows: list[dict[str, Any]] = [] query = dict(params or {}) while page <= page_limit: request_query = dict(query) request_query["per_page"] = per_page if cursor is not None: request_query["cursor"] = cursor else: # Keep backwards compatibility for endpoints still supporting page-based pagination. request_query["page"] = page payload = self.get_json(path, params=request_query) data = payload.get("data") or [] if isinstance(data, list): rows.extend(data) meta = payload.get("meta") or {} next_cursor = meta.get("next_cursor") if next_cursor: cursor = next_cursor page += 1 continue next_page = meta.get("next_page") if next_page: page = int(next_page) continue break return rows