app/apicache.go
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}