"""HTTP client for the FirstRate Data API. The user id is appended at request time and never logged. Vendor facts (verified 2026-11-01): auth = `userid` query parameter; archive endpoints answer HTTP 302 to a CDN (x01.frd-data.com / backblazeb2.com) serving a zip; `last_update` returns a bare date (fx uses yyyyMMdd); no documented rate limit — we still cap concurrency and sleep between metadata calls. """ from __future__ import annotations import hashlib import random import time from dataclasses import dataclass from datetime import date, datetime from pathlib import Path from typing import Any import httpx from findata_core.redact import redact BASE_URL = "https://firstratedata.com/api" FUNDAMENTALS_BASE_URL = "https://firstratedata.com/api-f" CHUNK = 4 * 1024 * 1024 MAX_RETRIES = 5 TIMEOUT_META = 60.0 TIMEOUT_DOWNLOAD = 7200.0 DEFAULT_TRANSPORT: httpx.BaseTransport | None = None # tests inject a MockTransport here class FRDError(RuntimeError): pass class FRDNoData(FRDError): """The vendor answered with a tiny text body instead of an archive (e.g. 'no update this period').""" @dataclass class Downloaded: path: Path sha256: str bytes: int final_url_host: str fetched_at: datetime class FRDClient: def __init__(self, userid: str, base_url: str = BASE_URL, min_interval_s: float = 1.0, transport: httpx.BaseTransport | None = None): if not userid: raise FRDError("FRD_USERID is not configured") self._userid = userid self.base_url = base_url.rstrip("/") self.min_interval_s = min_interval_s self._last_call = 0.0 self._client = httpx.Client(follow_redirects=True, timeout=TIMEOUT_META, transport=transport or DEFAULT_TRANSPORT, headers={"User-Agent": "FinDataOS/0.1 (+private research lakehouse)"}) # ----------------------------------------------------------------- helpers def _url(self, endpoint: str, params: dict[str, Any]) -> str: q = {k: str(v) for k, v in params.items() if v is not None} q["userid"] = self._userid return str(httpx.URL(f"{self.base_url}/{endpoint}", params=q)) def sanitized_url(self, endpoint: str, params: dict[str, Any]) -> str: q = {k: str(v) for k, v in params.items() if v is not None} return str(httpx.URL(f"{self.base_url}/{endpoint}", params=q)) + "&userid=" def _throttle(self) -> None: wait = self.min_interval_s - (time.monotonic() - self._last_call) if wait > 0: time.sleep(wait) self._last_call = time.monotonic() def _redact(self, text: Any) -> str: return redact(text, [self._userid]) def _get_text(self, endpoint: str, params: dict[str, Any]) -> str: last: Exception | None = None for attempt in range(1, MAX_RETRIES + 1): self._throttle() try: r = self._client.get(self._url(endpoint, params)) if r.status_code in (429, 500, 502, 503, 504): raise FRDError(f"HTTP {r.status_code} from {endpoint}") if r.status_code != 200: raise FRDError(f"HTTP {r.status_code} from {endpoint}: {self._redact(r.text[:200])}") return r.text except (httpx.TransportError, FRDError) as e: # retry last = e if isinstance(e, FRDError) and not any(c in str(e) for c in ("429", "500", "502", "503", "504")): raise FRDError(self._redact(e)) from None time.sleep(min(60, 2 ** attempt) + random.uniform(0, 1)) raise FRDError(self._redact(f"{endpoint} failed after {MAX_RETRIES} attempts: {last}")) # ----------------------------------------------------------------- metadata def last_update(self, type_: str, full: bool = False) -> date: txt = self._get_text("last_update", {"type": type_, "is_full_update": "true" if full else None}).strip() for fmt in ("%Y-%m-%d", "%Y%m%d"): try: return datetime.strptime(txt[:10] if fmt == "%Y-%m-%d" else txt[:8], fmt).date() except ValueError: continue raise FRDError(f"unparseable last_update for {type_}: {self._redact(txt[:40])!r}") def ticker_listing(self, type_: str) -> str: return self._get_text("ticker_listing", {"type": type_}) def update_log(self, type_: str, log_type: str) -> str: return self._get_text("update_log", {"type": type_, "log_type": log_type}) # ----------------------------------------------------------------- archives def download(self, endpoint: str, params: dict[str, Any], dest: Path) -> Downloaded: """Stream an archive to `dest` (atomic: `.part` then rename), computing SHA-256 on the fly.""" for i in range(20): try: dest.parent.mkdir(parents=True, exist_ok=True) break except InterruptedError: time.sleep(0.2 * (i + 1)) part = dest.with_suffix(dest.suffix + ".part") last: Exception | None = None for attempt in range(1, MAX_RETRIES + 1): self._throttle() h = hashlib.sha256() size = 0 try: with self._client.stream("GET", self._url(endpoint, params), timeout=TIMEOUT_DOWNLOAD) as r: if r.status_code != 200: body = r.read()[:300] raise FRDError(f"HTTP {r.status_code} downloading {endpoint}: {self._redact(body)}") ctype = r.headers.get("content-type", "") clen = int(r.headers.get("content-length") or 0) if "text/html" in ctype or (clen and clen < 512 and "zip" not in ctype): body = r.read()[:300] if clen and clen < 512: raise FRDNoData(f"no archive available for {endpoint}: {self._redact(body.decode(errors='replace').strip())}") raise FRDError(f"HTML instead of archive from {endpoint}: {self._redact(body)}") with open(part, "wb") as f: for chunk in r.iter_bytes(CHUNK): f.write(chunk) h.update(chunk) size += len(chunk) host = r.url.host part.replace(dest) return Downloaded(dest, h.hexdigest(), size, host, datetime.now().astimezone()) except (httpx.TransportError, FRDError, OSError) as e: last = e part.unlink(missing_ok=True) if isinstance(e, FRDNoData): raise if isinstance(e, FRDError) and "HTTP 4" in str(e): raise FRDError(self._redact(e)) from None time.sleep(min(120, 5 * 2 ** attempt) + random.uniform(0, 2)) raise FRDError(self._redact(f"download {endpoint} failed after {MAX_RETRIES} attempts: {last}")) def close(self) -> None: self._client.close()