Commit 276d65d64c

276d65d64cf3a833820cdaa1fcc71e2819d9540b

parent: bf87ba9fc5

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 02:45 UTC

Add an in-memory cache for DeviantArt API responses

Ref #8

Layout: unified · split

app/apicache.go added +204
@@ -0,0 +1,204 @@
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}
app/apicache_test.go added +203
@@ -0,0 +1,203 @@
1package app
2
3import (
4 "io"
5 "net/http"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10)
11
12// fakeRT is the upstream: it counts calls and returns a scripted response.
13type fakeRT struct {
14 mu sync.Mutex
15 calls int
16 status int
17 body string
18 delay time.Duration
19}
20
21func (f *fakeRT) RoundTrip(r *http.Request) (*http.Response, error) {
22 f.mu.Lock()
23 f.calls++
24 f.mu.Unlock()
25 time.Sleep(f.delay)
26 return &http.Response{
27 StatusCode: f.status,
28 Header: http.Header{"Content-Type": {"application/json"}},
29 Body: io.NopCloser(strings.NewReader(f.body)),
30 Request: r,
31 }, nil
32}
33
34func (f *fakeRT) count() int {
35 f.mu.Lock()
36 defer f.mu.Unlock()
37 return f.calls
38}
39
40const puppyURL = "https://www.deviantart.com/_puppy/dabrowse/search/all?q=fox&csrf_token=abc"
41
42func get(t *testing.T, rt http.RoundTripper, url string) (int, string) {
43 t.Helper()
44 req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:noctx // test request
45 if err != nil {
46 t.Fatal(err)
47 }
48 resp, err := rt.RoundTrip(req)
49 if err != nil {
50 t.Fatal(err)
51 }
52 defer func() { _ = resp.Body.Close() }()
53 body, _ := io.ReadAll(resp.Body)
54 return resp.StatusCode, string(body)
55}
56
57func TestSecondRequestIsServedFromCache(t *testing.T) {
58 up := &fakeRT{status: 200, body: `{"a":1}`}
59 rt := newAPICache(1<<20, time.Minute).transport(up)
60
61 get(t, rt, puppyURL)
62 status, body := get(t, rt, puppyURL)
63
64 if up.count() != 1 {
65 t.Errorf("upstream called %d times, want 1", up.count())
66 }
67 if status != 200 || body != `{"a":1}` {
68 t.Errorf("cached response is %d %q", status, body)
69 }
70}
71
72func TestExpiredEntryIsRefetched(t *testing.T) {
73 up := &fakeRT{status: 200, body: `{}`}
74 c := newAPICache(1<<20, time.Minute)
75 now := time.Now()
76 c.now = func() time.Time { return now }
77 rt := c.transport(up)
78
79 get(t, rt, puppyURL)
80 now = now.Add(2 * time.Minute)
81 get(t, rt, puppyURL)
82
83 if up.count() != 2 {
84 t.Errorf("upstream called %d times, want 2 after expiry", up.count())
85 }
86}
87
88func TestNon200IsNotStored(t *testing.T) {
89 up := &fakeRT{status: 403, body: "blocked"}
90 rt := newAPICache(1<<20, time.Minute).transport(up)
91
92 status, body := get(t, rt, puppyURL)
93 get(t, rt, puppyURL)
94
95 if status != 403 || body != "blocked" {
96 t.Errorf("first response is %d %q, want the upstream 403 passed through", status, body)
97 }
98 if up.count() != 2 {
99 t.Errorf("upstream called %d times, want 2: a 403 must not be cached", up.count())
100 }
101}
102
103func TestBypassesSessionAndOtherHosts(t *testing.T) {
104 up := &fakeRT{status: 200, body: "x"}
105 rt := newAPICache(1<<20, time.Minute).transport(up)
106
107 for _, url := range []string{
108 "https://www.deviantart.com/_puppy",
109 "https://www.deviantart.com",
110 "https://a.deviantart.net/avatars-big/a/alice.png",
111 } {
112 get(t, rt, url)
113 get(t, rt, url)
114 }
115 if up.count() != 6 {
116 t.Errorf("upstream called %d times, want 6: none of these URLs may be cached", up.count())
117 }
118}
119
120func TestKeyIgnoresCSRFToken(t *testing.T) {
121 up := &fakeRT{status: 200, body: "x"}
122 rt := newAPICache(1<<20, time.Minute).transport(up)
123
124 get(t, rt, puppyURL)
125 get(t, rt, strings.Replace(puppyURL, "csrf_token=abc", "csrf_token=def", 1))
126
127 if up.count() != 1 {
128 t.Errorf("upstream called %d times, want 1: a token refresh must not miss", up.count())
129 }
130}
131
132func TestByteBoundEvictsLeastRecentlyUsed(t *testing.T) {
133 up := &fakeRT{status: 200, body: strings.Repeat("x", 100)}
134 rt := newAPICache(250, time.Minute).transport(up)
135 a := "https://www.deviantart.com/_puppy/a?p=1"
136 b := "https://www.deviantart.com/_puppy/b?p=1"
137 c := "https://www.deviantart.com/_puppy/c?p=1"
138
139 get(t, rt, a)
140 get(t, rt, b)
141 get(t, rt, a) // a is now more recent than b
142 get(t, rt, c) // 300 bytes would exceed 250: b goes
143 get(t, rt, a)
144 get(t, rt, c)
145 get(t, rt, b)
146
147 if up.count() != 4 {
148 t.Errorf("upstream called %d times, want 4: only b should have been evicted", up.count())
149 }
150}
151
152func TestConcurrentMissesMakeOneUpstreamCall(t *testing.T) {
153 up := &fakeRT{status: 200, body: "x", delay: 50 * time.Millisecond}
154 rt := newAPICache(1<<20, time.Minute).transport(up)
155
156 var wg sync.WaitGroup
157 for range 20 {
158 wg.Go(func() { get(t, rt, puppyURL) })
159 }
160 wg.Wait()
161
162 if up.count() != 1 {
163 t.Errorf("upstream called %d times, want 1 for a burst on one key", up.count())
164 }
165}
166
167func TestStatsCountHitsAndMisses(t *testing.T) {
168 up := &fakeRT{status: 200, body: "abc"}
169 c := newAPICache(1<<20, time.Minute)
170 rt := c.transport(up)
171
172 get(t, rt, puppyURL)
173 get(t, rt, puppyURL)
174 get(t, rt, puppyURL)
175
176 hits, misses, entries, held := c.stats()
177 if hits != 2 || misses != 1 || entries != 1 || held != 3 {
178 t.Errorf("stats = %d hits, %d misses, %d entries, %d bytes; want 2, 1, 1, 3", hits, misses, entries, held)
179 }
180}
181
182// TestHitDoesNotConsumeAThrottleSlot pins the chain order: the cache sits in
183// front of the throttle, so a hit returns even when every throttle slot is
184// held. With the order reversed this test hangs and times out.
185func TestHitDoesNotConsumeAThrottleSlot(t *testing.T) {
186 up := &fakeRT{status: 200, body: "x"}
187 th := &daThrottle{base: up, sem: make(chan struct{}, 1)}
188 rt := newAPICache(1<<20, time.Minute).transport(th)
189
190 get(t, rt, puppyURL) // populate through the throttle
191
192 th.sem <- struct{}{} // hold the only slot
193 done := make(chan struct{})
194 go func() {
195 get(t, rt, puppyURL)
196 close(done)
197 }()
198 select {
199 case <-done:
200 case <-time.After(2 * time.Second):
201 t.Fatal("a cache hit waited on the throttle")
202 }
203}
go.mod +3 −1
@@ -1,8 +1,10 @@
1module skunkyart 1module skunkyart
2 2
3go 1.25.0 3go 1.26.0
4 4
5require ( 5require (
6 github.com/krazywarez/devianter v0.3.4 6 github.com/krazywarez/devianter v0.3.4
7 golang.org/x/net v0.58.0 7 golang.org/x/net v0.58.0
8) 8)
9
10require golang.org/x/sync v0.23.0
go.sum +2
@@ -2,3 +2,5 @@ github.com/krazywarez/devianter v0.3.4 h1:byeaLiH1jMi/0HvyqDBwg02p18NPIKl/KbmGEk
2github.com/krazywarez/devianter v0.3.4/go.mod h1:d+0cnLQqQNoiuVCuCMxvox3tLNj3n2vEWv7bqVn+2s8= 2github.com/krazywarez/devianter v0.3.4/go.mod h1:d+0cnLQqQNoiuVCuCMxvox3tLNj3n2vEWv7bqVn+2s8=
3golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= 3golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
4golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 4golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
5golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
6golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=