"""HTTP client for the backend's SSR content API (backend/api/content_routes.py). Fails loud at import if THERMOGRAPH_API_BASE_INTERNAL is unset -- same philosophy as content_loader.py's fail-loud content validation: a missing backend URL should break the boot, not silently 500 on the first request. A short TTL cache sits in front of every call: climate data changes at most hourly (the archive top-up + hourly recent-forecast refresh), so without this a page-view burst on one city would cost one backend round trip per request instead of one per cache window. This is in addition to (not instead of) the backend's own derived-store/ETag caching -- this cache saves the network hop entirely; the backend's saves the recompute. """ import os import time import httpx API_BASE = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL") if not API_BASE: raise RuntimeError("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)") # Backend's own routes (including the content API this client calls) sit # under ITS OWN THERMOGRAPH_BASE, exactly like content.py computes for # frontend's own routes -- both processes are always configured with the same # value in every real deployment (compose, run.sh, CI all set it identically # for both), so reading it here too keeps this client correct under a # sub-path deployment instead of only working when BASE is empty/root. _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") _BASE_PREFIX = f"/{_BASE}" if _BASE else "" _TTL = float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600) # 10 min default _cache: dict[str, tuple[float, object]] = {} def _get(path: str, *, origin: str | None = None) -> object: """GET {API_BASE}{path}, cached for _TTL seconds. Raises httpx.HTTPStatusError on a non-2xx response (including 404/503) so callers can map it the same way the old in-process code raised HTTPException. `origin` (e.g. "https://thermograph.org") is the ORIGINAL browser-facing request's origin, not this internal call's -- forwarded as Host/ X-Forwarded-Proto so the backend's own _origin(request) (which already prefers those headers) builds jsonld "url" fields against the public origin instead of this internal http://backend:8137-style address. Folded into the cache key: harmless when there's one canonical origin (the default same-domain prod topology), and correct when there's more than one (the genuinely-cross-origin capability Stage 5 proves out).""" cache_key = f"{origin}|{path}" if origin else path now = time.time() hit = _cache.get(cache_key) if hit and now - hit[0] < _TTL: return hit[1] headers = {} if origin: proto, _, host = origin.partition("://") headers = {"host": host, "x-forwarded-proto": proto} resp = httpx.get(f"{API_BASE}{_BASE_PREFIX}{path}", timeout=10.0, headers=headers) resp.raise_for_status() data = resp.json() _cache[cache_key] = (now, data) return data def hub() -> dict: return _get("/api/v2/content/hub") def sitemap() -> list[dict]: return _get("/api/v2/content/sitemap") def indexnow_key() -> str: return _get("/api/v2/content/indexnow-key")["key"] def home() -> dict: return _get("/api/v2/content/home") def city(slug: str, *, origin: str | None = None) -> dict: """Raises httpx.HTTPStatusError with .response.status_code 404 (unknown city) or 503 (warming) -- callers translate these to FastAPI HTTPException, same status codes content.py's _resolve_city raised in-process.""" return _get(f"/api/v2/content/city/{slug}", origin=origin) def city_month(slug: str, month: str) -> dict: return _get(f"/api/v2/content/city/{slug}/month/{month}") def city_records(slug: str, *, origin: str | None = None) -> dict: return _get(f"/api/v2/content/city/{slug}/records", origin=origin)