thermograph/api_client.py

99 lines
4.2 KiB
Python

"""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)")
# Single pin point for the backend content-API version this client speaks.
# Every path builder below reads this instead of hardcoding "v2" so a backend
# version bump is a one-line change here (plus the analogous JS pin in
# static/account.js) instead of a hunt-and-replace across ~10 files.
API_VERSION = os.environ.get("THERMOGRAPH_API_VERSION", "v2")
# 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(f"/api/{API_VERSION}/content/hub")
def sitemap() -> list[dict]:
return _get(f"/api/{API_VERSION}/content/sitemap")
def indexnow_key() -> str:
return _get(f"/api/{API_VERSION}/content/indexnow-key")["key"]
def home() -> dict:
return _get(f"/api/{API_VERSION}/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/{API_VERSION}/content/city/{slug}", origin=origin)
def city_month(slug: str, month: str) -> dict:
return _get(f"/api/{API_VERSION}/content/city/{slug}/month/{month}")
def city_records(slug: str, *, origin: str | None = None) -> dict:
return _get(f"/api/{API_VERSION}/content/city/{slug}/records", origin=origin)