thermograph/frontend/api_client.py

161 lines
6.7 KiB
Python
Raw Normal View History

"""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 threading
import time
from collections import OrderedDict
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 ""
# One pooled client shared by every call, instead of the old top-level
# httpx.get() (a brand-new connection -- fresh TCP, no keep-alive -- per
# request). Mirrors thermograph-backend's web/app.py _frontend_client.
_client = httpx.Client(base_url=API_BASE, timeout=10.0,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20))
_TTL = float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600) # 10 min default
# Bounded LRU: city()/city_records() fold the browser-facing `origin` (client-
# controlled via Host/X-Forwarded-Host, see content.py's _origin) into the
# cache key, so an unbounded dict here is a cheap memory-exhaustion vector --
# spoof enough distinct Host values and the cache grows forever. An
# OrderedDict is a full LRU with no extra dependency: move_to_end() on every
# hit/write, popitem(last=False) to evict the oldest when over the cap.
_CACHE_MAX_ENTRIES = 2048
_cache: "OrderedDict[str, tuple[float, object]]" = OrderedDict()
_cache_lock = threading.Lock()
# Per-key single-flight: without this, N concurrent requests that miss the
# same cache key (cold start, or right after TTL expiry on a hot city) each
# fire their own backend call -- a thundering herd. One lock per in-flight
# key makes every caller but the first block on, then reuse, that first
# caller's result instead of duplicating the request.
_inflight: dict[str, threading.Lock] = {}
_inflight_lock = threading.Lock()
def _cache_get(key: str) -> object | None:
with _cache_lock:
hit = _cache.get(key)
if hit is None:
return None
ts, data = hit
if time.time() - ts >= _TTL:
del _cache[key]
return None
_cache.move_to_end(key)
return data
def _cache_put(key: str, data: object) -> None:
with _cache_lock:
_cache[key] = (time.time(), data)
_cache.move_to_end(key)
while len(_cache) > _CACHE_MAX_ENTRIES:
_cache.popitem(last=False) # evict least-recently-used
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; raises httpx.TransportError
(ConnectError, TimeoutException, ...) when the backend can't be reached at
all -- callers map that to a 503 too (see content.py's _raise_api_error).
`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
hit = _cache_get(cache_key)
if hit is not None:
return hit
with _inflight_lock:
lock = _inflight.get(cache_key)
if lock is None:
lock = threading.Lock()
_inflight[cache_key] = lock
with lock:
hit = _cache_get(cache_key) # someone else may have filled it while we waited
if hit is not None:
return hit
try:
headers = {}
if origin:
proto, _, host = origin.partition("://")
headers = {"host": host, "x-forwarded-proto": proto}
resp = _client.get(f"{_BASE_PREFIX}{path}", headers=headers)
resp.raise_for_status()
data = resp.json()
_cache_put(cache_key, data)
return data
finally:
with _inflight_lock:
_inflight.pop(cache_key, None)
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)