"""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)