Trifecta-Lab / trifecta_bro /api /formfav_client.py
Brettapps's picture
Upload folder using huggingface_hub (part 21)
e23172f verified
Raw History Blame Contribute Delete
6.27 kB
"""FormFav API client.
Server-side only. Uses X-API-Key header (NOT Authorization: Bearer).
Handles retry/backoff, timeouts, caching, and graceful degradation when
premium fields (e.g. /predictions) are unavailable.
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
from pathlib import Path
from typing import Any, Optional
import httpx
from ..config import settings
logger = logging.getLogger("trifecta_bro.api")
class FormFavError(Exception):
"""Base error for FormFav interactions."""
class FormFavAuthError(FormFavError):
"""401/403 — key missing or lacks entitlement."""
class FormFavNotFoundError(FormFavError):
"""404 — invalid meeting/race params."""
class FormFavClient:
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
cache_dir: Optional[Path] = None,
cache_ttl: Optional[int] = None,
timeout: Optional[float] = None,
max_retries: Optional[int] = None,
) -> None:
self.api_key = api_key if api_key is not None else settings.api_key
self.base_url = (base_url or settings.base_url).rstrip("/")
self.cache_dir = Path(cache_dir or settings.cache_dir)
self.cache_ttl = cache_ttl if cache_ttl is not None else settings.cache_ttl_seconds
self.timeout = timeout if timeout is not None else settings.http_timeout
self.max_retries = max_retries if max_retries is not None else settings.max_retries
self.cache_dir.mkdir(parents=True, exist_ok=True)
# ----- low level -----
def _headers(self) -> dict[str, str]:
if not self.api_key:
raise FormFavAuthError("FORMFAV_API_KEY is not set in the environment.")
return {"X-API-Key": self.api_key, "Accept": "application/json"}
def _cache_path(self, key: str) -> Path:
h = hashlib.sha1(key.encode()).hexdigest()[:16]
return self.cache_dir / f"{h}.json"
def _cache_get(self, key: str) -> Optional[Any]:
p = self._cache_path(key)
if not p.exists():
return None
try:
if time.time() - p.stat().st_mtime > self.cache_ttl:
return None
return json.loads(p.read_text())
except Exception as exc: # pragma: no cover - defensive
logger.warning("cache read failed: %s", exc)
return None
def _cache_set(self, key: str, value: Any) -> None:
try:
self._cache_path(key).write_text(json.dumps(value))
except Exception as exc: # pragma: no cover - defensive
logger.warning("cache write failed: %s", exc)
def _request(self, path: str, params: dict[str, Any]) -> Any:
url = f"{self.base_url}{path}"
cache_key = f"{path}?{sorted((k, str(v)) for k, v in params.items())}"
cached = self._cache_get(cache_key)
if cached is not None:
logger.info("cache hit: %s", path)
return cached
last_exc: Optional[Exception] = None
for attempt in range(1, self.max_retries + 1):
try:
with httpx.Client(timeout=self.timeout) as client:
resp = client.get(url, headers=self._headers(), params=params)
if resp.status_code == 401:
raise FormFavAuthError("401 Unauthorized — check FORMFAV_API_KEY")
if resp.status_code == 403:
raise FormFavAuthError("403 Forbidden — key lacks entitlement for this endpoint")
if resp.status_code == 404:
raise FormFavNotFoundError(f"404 Not Found: {url} params={params}")
resp.raise_for_status()
data = resp.json()
self._cache_set(cache_key, data)
return data
except (FormFavAuthError, FormFavNotFoundError):
raise
except Exception as exc: # noqa: BLE001 - retry transient errors
last_exc = exc
wait = min(2 ** attempt, 30)
logger.warning("request attempt %s failed: %s (retry in %ss)", attempt, exc, wait)
if attempt < self.max_retries:
time.sleep(wait)
raise FormFavError(f"FormFav request failed after {self.max_retries} attempts: {last_exc}")
# ----- endpoints -----
def get_meetings(self, date: str, country: Optional[str] = None,
race_code: Optional[str] = None) -> list[dict[str, Any]]:
params = {"date": date}
if country or settings.country:
params["country"] = country or settings.country
if race_code or settings.race_code:
params["race_code"] = race_code or settings.race_code
data = self._request("/form/meetings", params)
return data.get("meetings", [])
def get_race_form(self, date: str, track: str, race: int | str,
country: Optional[str] = None,
race_code: Optional[str] = None,
timezone: Optional[str] = None) -> dict[str, Any]:
params = {
"date": date,
"track": track,
"race": race,
"country": country or settings.country,
"race_code": race_code or settings.race_code,
}
if timezone or settings.default_timezone:
params["timezone"] = timezone or settings.default_timezone
return self._request("/form", params)
def get_race_predictions(self, date: str, track: str, race: int | str,
country: Optional[str] = None,
race_code: Optional[str] = None) -> Optional[dict[str, Any]]:
"""Premium endpoint. Returns None when the key lacks entitlement."""
params = {
"date": date, "track": track, "race": race,
"country": country or settings.country,
"race_code": race_code or settings.race_code,
}
try:
return self._request("/predictions", params)
except FormFavAuthError:
logger.info("/predictions unavailable (403) — falling back to local model only")
return None