Vscode / app /providers /odds_api.py
Erinaldorodrigues's picture
Fix Odds API UTC timestamp format
626efb4
Raw
History Blame Contribute Delete
5.41 kB
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