160 lines
6.7 KiB
Python
160 lines
6.7 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 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)
|