app/apicache.go

v1.5.0
skunky-art/app/apicache.go history · blame · raw

204 lines · 5106 bytes

  1package app
  2
  3import (
  4	"bytes"
  5	"container/list"
  6	"io"
  7	"net/http"
  8	"strings"
  9	"sync"
 10	"time"
 11
 12	"golang.org/x/sync/singleflight"
 13)
 14
 15// apiCache holds DeviantArt API responses so that repeat and concurrent
 16// requests for one URL cost one upstream call. It sits in front of the
 17// throttle: a hit never touches DeviantArt or the throttle's budget.
 18//
 19// The store is bounded by body bytes with least-recently-used eviction. It
 20// is safe for concurrent use.
 21type apiCache struct {
 22	maxBytes int64
 23	ttl      time.Duration
 24	now      func() time.Time
 25
 26	mu      sync.Mutex
 27	entries map[string]*cacheEntry
 28	lru     *list.List // front is most recently used
 29	held    int64
 30	hits    int64
 31	misses  int64
 32
 33	flight singleflight.Group
 34}
 35
 36// cacheEntry is one buffered response. header is a clone of the upstream
 37// header; body is the whole body, read once.
 38type cacheEntry struct {
 39	key     string
 40	status  int
 41	header  http.Header
 42	body    []byte
 43	expires time.Time
 44	elem    *list.Element
 45}
 46
 47func newAPICache(maxBytes int64, ttl time.Duration) *apiCache {
 48	return &apiCache{
 49		maxBytes: maxBytes,
 50		ttl:      ttl,
 51		now:      time.Now,
 52		entries:  map[string]*cacheEntry{},
 53		lru:      list.New(),
 54	}
 55}
 56
 57// cacheable reports whether a request is one the cache handles: a GET to
 58// DeviantArt's API or its group search page. The session bootstrap (/_puppy
 59// with no path), the homepage, avatars and media all pass through.
 60func cacheable(req *http.Request) bool {
 61	if req.Method != http.MethodGet || req.URL.Host != "www.deviantart.com" {
 62		return false
 63	}
 64	p := req.URL.Path
 65	return (strings.HasPrefix(p, "/_puppy/") && len(p) > len("/_puppy/")) ||
 66		strings.HasPrefix(p, "/groups/")
 67}
 68
 69// cacheKey is the URL without csrf_token, which changes every twelve hours
 70// and would otherwise empty the cache on each refresh.
 71func cacheKey(req *http.Request) string {
 72	u := *req.URL
 73	q := u.Query()
 74	q.Del("csrf_token")
 75	u.RawQuery = q.Encode()
 76	return u.String()
 77}
 78
 79// transport returns a RoundTripper that answers from this cache and sends
 80// misses to base. Several transports may share one cache.
 81func (c *apiCache) transport(base http.RoundTripper) http.RoundTripper {
 82	return &cachedTransport{cache: c, base: base}
 83}
 84
 85type cachedTransport struct {
 86	cache *apiCache
 87	base  http.RoundTripper
 88}
 89
 90// RoundTrip serves a hit from memory. A miss is fetched once per key however
 91// many callers are waiting, buffered, stored if it is a 200, and handed to
 92// every waiter as its own response.
 93func (t *cachedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 94	if !cacheable(req) {
 95		return t.base.RoundTrip(req)
 96	}
 97	key := cacheKey(req)
 98	if e := t.cache.get(key); e != nil {
 99		return e.response(req), nil
100	}
101
102	v, err, _ := t.cache.flight.Do(key, func() (any, error) {
103		resp, err := t.base.RoundTrip(req)
104		if err != nil {
105			return nil, err
106		}
107		defer func() { _ = resp.Body.Close() }()
108		body, err := io.ReadAll(resp.Body)
109		if err != nil {
110			return nil, err
111		}
112		e := &cacheEntry{key: key, status: resp.StatusCode, header: resp.Header.Clone(), body: body}
113		if e.status == http.StatusOK {
114			t.cache.put(e)
115		}
116		return e, nil
117	})
118	if err != nil {
119		return nil, err
120	}
121	e, ok := v.(*cacheEntry)
122	if !ok {
123		return nil, io.ErrUnexpectedEOF
124	}
125	return e.response(req), nil
126}
127
128// response builds a fresh http.Response over the buffered body, so each
129// caller can read and close its own.
130func (e *cacheEntry) response(req *http.Request) *http.Response {
131	return &http.Response{
132		Status:        http.StatusText(e.status),
133		StatusCode:    e.status,
134		Proto:         "HTTP/1.1",
135		ProtoMajor:    1,
136		ProtoMinor:    1,
137		Header:        e.header.Clone(),
138		Body:          io.NopCloser(bytes.NewReader(e.body)),
139		ContentLength: int64(len(e.body)),
140		Request:       req,
141	}
142}
143
144// get returns the live entry for key, marking it most recently used, or nil.
145// An expired entry is dropped on the way out.
146func (c *apiCache) get(key string) *cacheEntry {
147	c.mu.Lock()
148	defer c.mu.Unlock()
149
150	e := c.entries[key]
151	if e == nil {
152		c.misses++
153		return nil
154	}
155	if !c.now().Before(e.expires) {
156		c.remove(e)
157		c.misses++
158		return nil
159	}
160	c.lru.MoveToFront(e.elem)
161	c.hits++
162	return e
163}
164
165// put stores e, evicting from the least recently used end until it fits. A
166// body larger than the whole bound is not stored.
167func (c *apiCache) put(e *cacheEntry) {
168	size := int64(len(e.body))
169	if size > c.maxBytes {
170		return
171	}
172
173	c.mu.Lock()
174	defer c.mu.Unlock()
175
176	if old := c.entries[e.key]; old != nil {
177		c.remove(old)
178	}
179	for c.held+size > c.maxBytes {
180		back, ok := c.lru.Back().Value.(*cacheEntry)
181		if !ok {
182			break
183		}
184		c.remove(back)
185	}
186	e.expires = c.now().Add(c.ttl)
187	e.elem = c.lru.PushFront(e)
188	c.entries[e.key] = e
189	c.held += size
190}
191
192// remove drops e. The caller holds mu.
193func (c *apiCache) remove(e *cacheEntry) {
194	c.lru.Remove(e.elem)
195	delete(c.entries, e.key)
196	c.held -= int64(len(e.body))
197}
198
199// stats reports the counters for the hourly log line.
200func (c *apiCache) stats() (hits, misses int64, entries int, held int64) {
201	c.mu.Lock()
202	defer c.mu.Unlock()
203	return c.hits, c.misses, len(c.entries), c.held
204}