// 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 }