thermograph/frontend/server/internal/contentapi/client.go

330 lines
11 KiB
Go
Raw Normal View History

// Package contentapi is the HTTP client for the backend's SSR content API
// (backend/api/content_routes.py) — the Go port of api_client.py, mirroring
// its behavior exactly:
//
// - 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.
// - Bounded LRU: City()/CityRecords() fold the browser-facing origin
// (client-controlled via Host/X-Forwarded-Host) into the cache key, so an
// unbounded map is a cheap memory-exhaustion vector — spoof enough
// distinct Host values and the cache grows forever.
// - Per-key single-flight: without it, 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.
//
// Note on ETag / If-None-Match: this internal client never sends conditional
// requests — the Python client didn't either. The ETag half of the caching
// contract lives on the SERVING side (the W/"sha1…" ETags this service stamps
// on rendered HTML, see internal/render) and in the browser (static/cache.js's
// If-None-Match refetch of /api/v2/cell). Between this process and the backend
// the freshness mechanism is purely the TTL cache below.
package contentapi
import (
"container/list"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// DefaultCacheMaxEntries mirrors api_client.py's _CACHE_MAX_ENTRIES.
const DefaultCacheMaxEntries = 2048
// StatusError is a non-2xx backend response — the analogue of
// httpx.HTTPStatusError. Callers map StatusCode straight through (the
// backend's 404 unknown-city and 503 warming become the page's own status);
// any other error from a Client method never got a response at all (a backend
// blip or restart) and maps to a 503, same as content.py's _raise_api_error.
type StatusError struct {
StatusCode int
Body string // response body text, forwarded as the error detail
URL string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("backend returned %d for %s", e.StatusCode, e.URL)
}
// Options configures New. BaseURL is required; zero values elsewhere take the
// documented defaults.
type Options struct {
// BaseURL is THERMOGRAPH_API_BASE_INTERNAL (e.g. http://backend:8137).
BaseURL string
// BasePrefix is the backend's own THERMOGRAPH_BASE, normalized like
// config.Config.Base ("" or "/x"). Both processes are always configured
// with the same value in every real deployment, so reusing frontend's
// keeps this client correct under a sub-path deployment instead of only
// working when BASE is empty/root.
BasePrefix string
// APIVersion is the pinned content-API version (default "v2"). See the
// API-version pinning contract in frontend CLAUDE.md before changing.
APIVersion string
// TTL is the response-cache lifetime (THERMOGRAPH_SSR_CACHE_TTL);
// default 600s.
TTL time.Duration
// MaxCacheEntries caps the LRU (default DefaultCacheMaxEntries).
MaxCacheEntries int
// HTTPClient overrides the default pooled client (tests). The default
// mirrors api_client.py's httpx.Client: 10s total timeout, up to 100
// connections with 20 kept alive.
HTTPClient *http.Client
}
// Client is safe for concurrent use.
type Client struct {
baseURL string
basePrefix string
apiVersion string
ttl time.Duration
maxEntries int
http *http.Client
mu sync.Mutex
cache map[string]*list.Element
order *list.List // front = most recently used
inflightMu sync.Mutex
inflight map[string]*sync.Mutex
}
type cacheEntry struct {
key string
ts time.Time
data any
}
// New builds a Client. It does not touch the network.
func New(o Options) *Client {
if o.APIVersion == "" {
o.APIVersion = "v2"
}
if o.TTL == 0 {
o.TTL = 600 * time.Second
}
if o.MaxCacheEntries == 0 {
o.MaxCacheEntries = DefaultCacheMaxEntries
}
hc := o.HTTPClient
if hc == nil {
hc = &http.Client{
Timeout: 10 * time.Second, // httpx.Client(timeout=10.0)
Transport: &http.Transport{
MaxConnsPerHost: 100, // httpx.Limits(max_connections=100, …)
MaxIdleConnsPerHost: 20, // …max_keepalive_connections=20
},
}
}
return &Client{
baseURL: strings.TrimRight(o.BaseURL, "/"),
basePrefix: o.BasePrefix,
apiVersion: o.APIVersion,
ttl: o.TTL,
maxEntries: o.MaxCacheEntries,
http: hc,
cache: make(map[string]*list.Element),
order: list.New(),
inflight: make(map[string]*sync.Mutex),
}
}
func (c *Client) cacheGet(key string) any {
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.cache[key]
if !ok {
return nil
}
ent := el.Value.(*cacheEntry)
if time.Since(ent.ts) >= c.ttl {
c.order.Remove(el)
delete(c.cache, key)
return nil
}
c.order.MoveToFront(el)
return ent.data
}
func (c *Client) cachePut(key string, data any) {
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.cache[key]; ok {
ent := el.Value.(*cacheEntry)
ent.ts, ent.data = time.Now(), data
c.order.MoveToFront(el)
} else {
c.cache[key] = c.order.PushFront(&cacheEntry{key: key, ts: time.Now(), data: data})
}
for len(c.cache) > c.maxEntries {
oldest := c.order.Back() // evict least-recently-used
c.order.Remove(oldest)
delete(c.cache, oldest.Value.(*cacheEntry).key)
}
}
// get GETs {BaseURL}{BasePrefix}{path}, cached for TTL. decode turns the
// response body into the typed payload that gets cached and returned.
//
// 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. Empty
// string means "no origin to forward".
func (c *Client) get(path, origin string, decode func([]byte) (any, error)) (any, error) {
cacheKey := path
if origin != "" {
cacheKey = origin + "|" + path
}
if hit := c.cacheGet(cacheKey); hit != nil {
return hit, nil
}
c.inflightMu.Lock()
lock, ok := c.inflight[cacheKey]
if !ok {
lock = &sync.Mutex{}
c.inflight[cacheKey] = lock
}
c.inflightMu.Unlock()
lock.Lock()
defer lock.Unlock()
// Someone else may have filled it while we waited on the flight lock.
if hit := c.cacheGet(cacheKey); hit != nil {
return hit, nil
}
// Mirror the Python's finally: the in-flight entry goes away whether the
// fetch succeeded or not — errors are never cached.
defer func() {
c.inflightMu.Lock()
delete(c.inflight, cacheKey)
c.inflightMu.Unlock()
}()
body, err := c.fetch(path, origin)
if err != nil {
return nil, err
}
data, err := decode(body)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
c.cachePut(cacheKey, data)
return data, nil
}
func (c *Client) fetch(path, origin string) ([]byte, error) {
url := c.baseURL + c.basePrefix + path
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if origin != "" {
// origin.partition("://"): everything after the separator is the host
// (empty if the separator is missing, same as the Python).
proto, host, _ := strings.Cut(origin, "://")
req.Host = host
req.Header.Set("X-Forwarded-Proto", proto)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err // transport-level: callers map to 503
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, &StatusError{StatusCode: resp.StatusCode, Body: string(body), URL: url}
}
return body, nil
}
// decodeInto adapts a typed unmarshal to get's decode callback.
func decodeInto[T any](body []byte) (any, error) {
v := new(T)
if err := json.Unmarshal(body, v); err != nil {
return nil, err
}
return v, nil
}
// Hub returns the climate-hub payload (/content/hub).
func (c *Client) Hub() (*HubPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/hub", c.apiVersion), "", decodeInto[HubPayload])
if err != nil {
return nil, err
}
return v.(*HubPayload), nil
}
// Sitemap returns every sitemap entry (/content/sitemap).
func (c *Client) Sitemap() ([]SitemapEntry, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/sitemap", c.apiVersion), "", decodeInto[[]SitemapEntry])
if err != nil {
return nil, err
}
return *v.(*[]SitemapEntry), nil
}
// IndexNowKey returns the IndexNow verification key (/content/indexnow-key).
func (c *Client) IndexNowKey() (string, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/indexnow-key", c.apiVersion), "", decodeInto[indexNowPayload])
if err != nil {
return "", err
}
return v.(*indexNowPayload).Key, nil
}
// Home returns the homepage payload (/content/home).
func (c *Client) Home() (*HomePayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/home", c.apiVersion), "", decodeInto[HomePayload])
if err != nil {
return nil, err
}
return v.(*HomePayload), nil
}
// City returns one city's climate-page payload. A *StatusError with
// StatusCode 404 (unknown city) or 503 (warming) maps straight through to the
// page response, same status codes the old in-process code raised. origin ""
// omits the Host/X-Forwarded-Proto forwarding.
func (c *Client) City(slug, origin string) (*CityPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s", c.apiVersion, slug), origin, decodeInto[CityPayload])
if err != nil {
return nil, err
}
return v.(*CityPayload), nil
}
// CityMonth returns one city-month payload.
func (c *Client) CityMonth(slug, month string) (*MonthPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s/month/%s", c.apiVersion, slug, month), "", decodeInto[MonthPayload])
if err != nil {
return nil, err
}
return v.(*MonthPayload), nil
}
// CityRecords returns one city's records-page payload.
func (c *Client) CityRecords(slug, origin string) (*RecordsPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s/records", c.apiVersion, slug), origin, decodeInto[RecordsPayload])
if err != nil {
return nil, err
}
return v.(*RecordsPayload), nil
}