Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
329 lines
11 KiB
Go
329 lines
11 KiB
Go
// 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
|
|
}
|