85 lines
3.2 KiB
Python
85 lines
3.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)")
|
||
|
|
|
||
|
|
_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}{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)
|