from __future__ import annotations from datetime import datetime, timedelta, timezone import asyncio import logging from typing import Any from app.providers.http_client import ResilientHTTP, ProviderError logger = logging.getLogger(__name__) def _odds_api_timestamp(value: datetime) -> str: """Serialize UTC timestamps using the exact second-level format required by v4.""" return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") class OddsAPIProvider: BASE = "https://api.the-odds-api.com/v4" def __init__(self, api_key: str, http: ResilientHTTP, regions: str = "eu"): self.api_key = api_key self.http = http self.regions = regions self.region_count = max(1, len([part for part in regions.split(",") if part])) self.quota = {"remaining": None, "used": None, "last": None} self.inactive_keys: list[str] = [] self.queried_keys: list[str] = [] self.errors: list[str] = [] async def _active_sport_keys(self) -> set[str] | None: try: payload, _ = await self.http.get_json( f"{self.BASE}/sports/", params={"apiKey": self.api_key}, ) return { str(item.get("key")) for item in (payload or []) if item.get("active") is not False and item.get("key") } except Exception as exc: logger.warning("Não foi possível consultar lista gratuita de esportes ativos: %s", exc) return None async def fetch_events(self, sport_keys: tuple[str, ...], horizon_hours: int) -> list[dict[str, Any]]: if not self.api_key: raise RuntimeError("ODDS_API_KEY não configurado") active = await self._active_sport_keys() if active is not None: self.inactive_keys = [k for k in sport_keys if k not in active] sport_keys = tuple(k for k in sport_keys if k in active) now = datetime.now(timezone.utc) end = now + timedelta(hours=horizon_hours) out: list[dict[str, Any]] = [] successful_requests = 0 for key in sport_keys: remaining = self.quota.get("remaining") reserve = max(25, self.region_count * 3) if isinstance(remaining, int) and remaining <= reserve: logger.warning( "Quota Guardian: apenas %d créditos restantes (reserva %d); interrompendo novas ligas", remaining, reserve, ) break params = { "apiKey": self.api_key, "regions": self.regions, "markets": "h2h", "oddsFormat": "decimal", "dateFormat": "iso", "commenceTimeFrom": _odds_api_timestamp(now), "commenceTimeTo": _odds_api_timestamp(end), } self.queried_keys.append(key) try: payload, headers = await self.http.get_json( f"{self.BASE}/sports/{key}/odds/", params=params, allow_status={404}, ) except ProviderError as exc: if exc.status_code in {401, 403, 429}: raise logger.warning("Odds API falhou para %s: %s", key, exc) self.errors.append(f"{key}: {exc}") continue self._read_quota(headers) if payload is None: if key not in self.inactive_keys: self.inactive_keys.append(key) successful_requests += 1 continue if not isinstance(payload, list): self.errors.append(f"{key}: resposta de odds fora do formato esperado") continue successful_requests += 1 if payload: for event in payload: if not isinstance(event, dict): continue event["_sport_key"] = key out.append(event) await asyncio.sleep(0.10) if self.queried_keys and successful_requests == 0 and self.errors: raise ProviderError( "Todas as consultas de odds falharam; o estado anterior foi preservado" ) unique: dict[str, dict[str, Any]] = {} for event in out: event_id = str(event.get("id") or "") if event_id: unique[event_id] = event logger.info( "The Odds API: %d eventos carregados (%d ligas inativas ignoradas)", len(unique), len(self.inactive_keys), ) return list(unique.values()) def _read_quota(self, headers) -> None: def _number(name: str): raw = headers.get(name) try: return int(raw) if raw is not None else None except (TypeError, ValueError): return raw remaining = _number("x-requests-remaining") used = _number("x-requests-used") last = _number("x-requests-last") if remaining is not None: self.quota["remaining"] = remaining if used is not None: self.quota["used"] = used if last is not None: self.quota["last"] = last