Cache DeviantArt API responses in memory !8

merged merged by cmc on 2026-09-11 15:13 UTC · krz/skunky-art:feat/api-cache into main

13 files changed, +1479 −46

Layout: unified · split

.mailmap deleted −4
@@ -1,4 +0,0 @@
1lost+skunk <skunky@ebloid.ru> <skunky@macaw.me>
2lost+skunk <skunky@ebloid.ru> <me@lost-skunk.cc>
3lost+skunk <skunky@ebloid.ru> <skunky@noreply.git.macaw.me>
4lost+skunk <skunky@ebloid.ru> skunky <skunky@ebloid.ru>
SETUP.md +11
@@ -3,6 +3,7 @@ Maximum file size in megabytes, requires numeric value.<br>
33Time units:
44* `i` — minutes
55* `h` — hours
6* `d` — days
67* `w` — weeks
78* `m` — months
89* `y` — years
@@ -23,6 +24,16 @@ Time units:
2324 * `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details)
2425 * `max-size` — Maximum file size in megabytes
2526 * `update-interval` — Automatic rotation interval
27* `api-cache` — In-memory cache of DeviantArt API responses. Every page,
28 feed poll and API call that asks DeviantArt the same question within the
29 TTL is answered from memory, and concurrent requests for one thing make
30 one upstream call. On by default; DeviantArt bans egress IPs that ask too
31 often, so leave it on unless you are debugging.
32 * `enabled` — boolean, default true
33 * `max-size` — megabytes of response bodies to hold, default 64. Least
34 recently used entries are dropped past this.
35 * `ttl` — how long a response is reused, in the time units above. Default
36 `5i`.
2637* `static-path` — This setting determines path to static, which will be copied to RAM when SkunkyArt is started. Useless if you're use binary compiled with 'embed' tag.
2738* `download-proxy` — Outbound proxy used when fetching media from DeviantArt's
2839 CDN. Leave empty (`""`) unless you actually run a proxy: if this points at
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}
app/config.go +73 −33
@@ -2,6 +2,7 @@ package app
22
33import (
44 "encoding/json"
5 "errors"
56 "os"
67 "regexp"
78 "skunkyart/static"
@@ -27,19 +28,26 @@ type cacheConfig struct {
2728 UpdateInterval int64 `json:"update-interval"`
2829}
2930
31type apiCacheConfig struct {
32 Enabled bool `json:"enabled"`
33 MaxSize int64 `json:"max-size"`
34 TTL string `json:"ttl"`
35}
36
3037type config struct {
3138 cfg string
32 Listen string `json:"listen"`
33 URI string `json:"uri"`
34 Cache cacheConfig `json:"cache"`
35 Proxy bool `json:"proxy"`
36 Nsfw bool `json:"nsfw"`
37 HideAI bool `json:"hide-ai"`
38 Theme string `json:"theme"`
39 Language string `json:"language"`
40 UserAgent string `json:"user-agent"`
41 DownloadProxy string `json:"download-proxy"`
42 StaticPath string `json:"static-path"`
39 Listen string `json:"listen"`
40 URI string `json:"uri"`
41 Cache cacheConfig `json:"cache"`
42 APICache apiCacheConfig `json:"api-cache"`
43 Proxy bool `json:"proxy"`
44 Nsfw bool `json:"nsfw"`
45 HideAI bool `json:"hide-ai"`
46 Theme string `json:"theme"`
47 Language string `json:"language"`
48 UserAgent string `json:"user-agent"`
49 DownloadProxy string `json:"download-proxy"`
50 StaticPath string `json:"static-path"`
4351}
4452
4553// CFG is the running instance's configuration, holding the defaults below until
@@ -55,6 +63,11 @@ var CFG = config{
5563 Path: "cache",
5664 UpdateInterval: 1,
5765 },
66 APICache: apiCacheConfig{
67 Enabled: true,
68 MaxSize: 64,
69 TTL: "5i",
70 },
5871 StaticPath: "static",
5972 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
6073 Proxy: true,
@@ -63,6 +76,43 @@ var CFG = config{
6376
6477var lifetimeParsed int64
6578
79// apiCacheTTL is api-cache.ttl parsed, set by ExecuteConfig.
80var apiCacheTTL time.Duration
81
82// parseLifetime reads a duration in the config's unit syntax: a number
83// followed by i (minutes), h (hours), d (days), w (weeks), m (30-day
84// months) or y (360-day years).
85func parseLifetime(s string) (time.Duration, error) {
86 if s == "" {
87 return 0, errors.New("empty lifetime")
88 }
89 numstr := regexp.MustCompile("[0-9]+").FindAllString(s, -1)
90 if len(numstr) == 0 {
91 return 0, errors.New("lifetime has no number: " + s)
92 }
93 num, _ := strconv.Atoi(numstr[len(numstr)-1])
94
95 day := 24 * time.Hour
96 var unit time.Duration
97 switch s[len(s)-1:] {
98 case "i":
99 unit = time.Minute
100 case "h":
101 unit = time.Hour
102 case "d":
103 unit = day
104 case "w":
105 unit = 7 * day
106 case "m":
107 unit = 30 * day
108 case "y":
109 unit = 360 * day
110 default:
111 return 0, errors.New("invalid unit specified: " + s[len(s)-1:])
112 }
113 return unit * time.Duration(num), nil
114}
115
66116// checkCacheWritable creates the cache directory if it is missing and confirms
67117// this process can actually write into it, returning the error that a real cache
68118// write would hit.
@@ -104,29 +154,11 @@ func ExecuteConfig() {
104154 }
105155
106156 if CFG.Cache.Lifetime != "" {
107 var duration int64
108 day := 24 * time.Hour.Milliseconds()
109 numstr := regexp.MustCompile("[0-9]+").FindAllString(CFG.Cache.Lifetime, -1)
110 num, _ := strconv.Atoi(numstr[len(numstr)-1])
111
112 switch unit := CFG.Cache.Lifetime[len(CFG.Cache.Lifetime)-1:]; unit {
113 case "i":
114 duration = time.Minute.Milliseconds()
115 case "h":
116 duration = time.Hour.Milliseconds()
117 case "d":
118 duration = day
119 case "w":
120 duration = day * 7
121 case "m":
122 duration = day * 30
123 case "y":
124 duration = day * 360
125 default:
126 exit("Invalid unit specified: "+unit, 1)
157 d, err := parseLifetime(CFG.Cache.Lifetime)
158 if err != nil {
159 exit("config: cache.lifetime: "+err.Error(), 1)
127160 }
128
129 lifetimeParsed = duration * int64(num)
161 lifetimeParsed = d.Milliseconds()
130162 }
131163 // max-size is documented in megabytes. This was 1024^2, which in Go is
132164 // XOR (1026), not exponentiation — so the cap was ~1000x too small.
@@ -152,6 +184,14 @@ func ExecuteConfig() {
152184 exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1)
153185 }
154186
187 if CFG.APICache.Enabled {
188 d, err := parseLifetime(CFG.APICache.TTL)
189 if err != nil {
190 exit("config: api-cache.ttl: "+err.Error(), 1)
191 }
192 apiCacheTTL = d
193 }
194
155195 static.StaticPath = CFG.StaticPath
156196 devianter.UserAgent = CFG.UserAgent
157197 }
app/config_test.go added +38
@@ -0,0 +1,38 @@
1package app
2
3import (
4 "testing"
5 "time"
6)
7
8func TestParseLifetimeUnits(t *testing.T) {
9 cases := map[string]time.Duration{
10 "5i": 5 * time.Minute,
11 "2h": 2 * time.Hour,
12 "3d": 72 * time.Hour,
13 "1w": 7 * 24 * time.Hour,
14 "1m": 30 * 24 * time.Hour,
15 "1y": 360 * 24 * time.Hour,
16 "12i": 12 * time.Minute,
17 }
18 for in, want := range cases {
19 got, err := parseLifetime(in)
20 if err != nil || got != want {
21 t.Errorf("parseLifetime(%q) = %v, %v; want %v", in, got, err, want)
22 }
23 }
24}
25
26func TestParseLifetimeRejectsBadInput(t *testing.T) {
27 for _, in := range []string{"", "5", "5x", "h"} {
28 if _, err := parseLifetime(in); err == nil {
29 t.Errorf("parseLifetime(%q) accepted, want an error", in)
30 }
31 }
32}
33
34func TestAPICacheDefaults(t *testing.T) {
35 if !CFG.APICache.Enabled || CFG.APICache.MaxSize != 64 || CFG.APICache.TTL != "5i" {
36 t.Errorf("defaults are %+v, want enabled, 64 MB, 5i", CFG.APICache)
37 }
38}
app/httpclient.go +33 −4
@@ -81,11 +81,40 @@ func tunedTransport() *http.Transport {
8181 return t
8282}
8383
84// InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits and
85// timeouts above. Call once at startup, before any DeviantArt request is made.
84// daCache is the API response cache shared by every transport, or nil when
85// api-cache.enabled is false.
86var daCache *apiCache
87
88// chain wraps base with the throttle and, when enabled, the cache in front
89// of it, so a hit never spends a throttle slot.
90func chain(base http.RoundTripper) http.RoundTripper {
91 rt := throttled(base)
92 if daCache != nil {
93 return daCache.transport(rt)
94 }
95 return rt
96}
97
98// logCacheStatsForever prints one line an hour so an operator can see the
99// cache working without an endpoint. Run it in its own goroutine.
100func logCacheStatsForever(c *apiCache) {
101 for {
102 time.Sleep(time.Hour)
103 hits, misses, entries, held := c.stats()
104 println("api cache:", hits, "hits,", misses, "misses,", entries, "entries,", held>>20, "MB held")
105 }
106}
107
108// InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits
109// and timeouts above, and with the API response cache when it is enabled. Call
110// once at startup, after ExecuteConfig and before any DeviantArt request.
86111func InstallDAThrottle() {
87112 baseTransport = tunedTransport()
88 http.DefaultTransport = throttled(baseTransport)
113 if CFG.APICache.Enabled {
114 daCache = newAPICache(CFG.APICache.MaxSize<<20, apiCacheTTL)
115 go logCacheStatsForever(daCache)
116 }
117 http.DefaultTransport = chain(baseTransport)
89118}
90119
91120// throttled wraps base with the DeviantArt rate and concurrency limits.
@@ -104,5 +133,5 @@ func ProxiedTransport(proxy *url.URL) http.RoundTripper {
104133 base = tunedTransport()
105134 }
106135 base.Proxy = http.ProxyURL(proxy)
107 return throttled(base)
136 return chain(base)
108137}
app/httpclient_test.go +14 −4
@@ -116,14 +116,24 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { retu
116116// InstallDAThrottle must preserve proxy-from-environment so HTTPS_PROXY (VPN
117117// egress) keeps working, and must not panic on a repeat call.
118118func TestInstallDAThrottlePreservesProxy(t *testing.T) {
119 orig := http.DefaultTransport
120 defer func() { http.DefaultTransport = orig }()
119 orig, origCache := http.DefaultTransport, daCache
120 defer func() { http.DefaultTransport, daCache = orig, origCache }()
121121
122122 InstallDAThrottle()
123123
124 th, ok := http.DefaultTransport.(*daThrottle)
124 // With api-cache on (the default) the cache is outermost and the throttle
125 // sits inside it; with it off the throttle is outermost.
126 rt := http.DefaultTransport
127 if CFG.APICache.Enabled {
128 ct, ok := rt.(*cachedTransport)
129 if !ok {
130 t.Fatalf("DefaultTransport is not the cache, got %T", rt)
131 }
132 rt = ct.base
133 }
134 th, ok := rt.(*daThrottle)
125135 if !ok {
126 t.Fatalf("DefaultTransport was not wrapped, got %T", http.DefaultTransport)
136 t.Fatalf("DefaultTransport was not wrapped by the throttle, got %T", rt)
127137 }
128138 base, ok := th.base.(*http.Transport)
129139 if !ok {
config.example.json +5
@@ -9,6 +9,11 @@
99 "memcache": false,
1010 "update-interval": 5
1111 },
12 "api-cache": {
13 "enabled": true,
14 "max-size": 64,
15 "ttl": "5i"
16 },
1217 "static-path": "static",
1318 "download-proxy": "",
1419 "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
docs/superpowers/plans/2026-09-10-api-cache.md added +776
@@ -0,0 +1,776 @@
1# API Response Cache Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Cache DeviantArt API responses in memory so repeat and concurrent
6page views make one upstream request instead of many.
7
8**Architecture:** A caching `http.RoundTripper` sits in front of the
9existing throttle. It stores 200 responses for `/_puppy/` and `/groups/`
10GETs keyed by URL minus `csrf_token`, bounded by bytes with LRU eviction,
11with singleflight coalescing of concurrent misses.
12
13**Tech Stack:** Go 1.25, `golang.org/x/sync/singleflight`, standard
14library `container/list`.
15
16**Spec:** `docs/superpowers/specs/2026-09-10-api-cache-design.md`
17
18## Global Constraints
19
20- Go module is `skunkyart`; package under test is `app`.
21- Lint must stay clean: `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./...`.
22- Tests run with `go test ./app -count=1 -race`.
23- No attribution trailers in commits.
24- Errors and log lines are plain sentences, lowercase error strings.
25
26---
27
28### Task 1: Shared lifetime parser and the `api-cache` config block
29
30**Files:**
31- Modify: `app/config.go`
32- Create: `app/config_test.go`
33- Modify: `config.example.json`, `SETUP.md`
34
35**Interfaces:**
36- Produces: `func parseLifetime(s string) (time.Duration, error)`;
37 `CFG.APICache` of type `apiCacheConfig{Enabled bool; MaxSize int64; TTL string}`;
38 package var `apiCacheTTL time.Duration` set by `ExecuteConfig`.
39
40- [ ] **Step 1: Write the failing tests**
41
42```go
43package app
44
45import (
46 "testing"
47 "time"
48)
49
50func TestParseLifetimeUnits(t *testing.T) {
51 cases := map[string]time.Duration{
52 "5i": 5 * time.Minute,
53 "2h": 2 * time.Hour,
54 "3d": 72 * time.Hour,
55 "1w": 7 * 24 * time.Hour,
56 "1m": 30 * 24 * time.Hour,
57 "1y": 360 * 24 * time.Hour,
58 "12i": 12 * time.Minute,
59 }
60 for in, want := range cases {
61 got, err := parseLifetime(in)
62 if err != nil || got != want {
63 t.Errorf("parseLifetime(%q) = %v, %v; want %v", in, got, err, want)
64 }
65 }
66}
67
68func TestParseLifetimeRejectsBadInput(t *testing.T) {
69 for _, in := range []string{"", "5", "5x", "h"} {
70 if _, err := parseLifetime(in); err == nil {
71 t.Errorf("parseLifetime(%q) accepted, want an error", in)
72 }
73 }
74}
75
76func TestAPICacheDefaults(t *testing.T) {
77 if !CFG.APICache.Enabled || CFG.APICache.MaxSize != 64 || CFG.APICache.TTL != "5i" {
78 t.Errorf("defaults are %+v, want enabled, 64 MB, 5i", CFG.APICache)
79 }
80}
81```
82
83- [ ] **Step 2: Run to verify it fails**
84
85Run: `go test ./app -run 'TestParseLifetime|TestAPICacheDefaults' -count=1`
86Expected: FAIL, `undefined: parseLifetime`.
87
88- [ ] **Step 3: Implement**
89
90In `app/config.go`, add `"errors"` to the imports. Add the type and the
91field:
92
93```go
94type apiCacheConfig struct {
95 Enabled bool `json:"enabled"`
96 MaxSize int64 `json:"max-size"`
97 TTL string `json:"ttl"`
98}
99```
100
101In `config`, after `Cache`:
102
103```go
104 APICache apiCacheConfig `json:"api-cache"`
105```
106
107In the `CFG` literal, after the `Cache:` block:
108
109```go
110 APICache: apiCacheConfig{
111 Enabled: true,
112 MaxSize: 64,
113 TTL: "5i",
114 },
115```
116
117After `var lifetimeParsed int64`:
118
119```go
120// apiCacheTTL is api-cache.ttl parsed, set by ExecuteConfig.
121var apiCacheTTL time.Duration
122
123// parseLifetime reads a duration in the config's unit syntax: a number
124// followed by i (minutes), h (hours), d (days), w (weeks), m (30-day
125// months) or y (360-day years).
126func parseLifetime(s string) (time.Duration, error) {
127 if s == "" {
128 return 0, errors.New("empty lifetime")
129 }
130 numstr := regexp.MustCompile("[0-9]+").FindAllString(s, -1)
131 if len(numstr) == 0 {
132 return 0, errors.New("lifetime has no number: " + s)
133 }
134 num, _ := strconv.Atoi(numstr[len(numstr)-1])
135
136 day := 24 * time.Hour
137 var unit time.Duration
138 switch s[len(s)-1:] {
139 case "i":
140 unit = time.Minute
141 case "h":
142 unit = time.Hour
143 case "d":
144 unit = day
145 case "w":
146 unit = 7 * day
147 case "m":
148 unit = 30 * day
149 case "y":
150 unit = 360 * day
151 default:
152 return 0, errors.New("invalid unit specified: " + s[len(s)-1:])
153 }
154 return unit * time.Duration(num), nil
155}
156```
157
158Replace the `if CFG.Cache.Lifetime != "" { ... }` block inside
159`ExecuteConfig` with:
160
161```go
162 if CFG.Cache.Lifetime != "" {
163 d, err := parseLifetime(CFG.Cache.Lifetime)
164 if err != nil {
165 exit("config: cache.lifetime: "+err.Error(), 1)
166 }
167 lifetimeParsed = d.Milliseconds()
168 }
169```
170
171After the theme switch, before `static.StaticPath = CFG.StaticPath`:
172
173```go
174 if CFG.APICache.Enabled {
175 d, err := parseLifetime(CFG.APICache.TTL)
176 if err != nil {
177 exit("config: api-cache.ttl: "+err.Error(), 1)
178 }
179 apiCacheTTL = d
180 }
181```
182
183Remove the now-unused `day` and `duration` locals from `ExecuteConfig`.
184
185In `config.example.json`, after the `cache` block:
186
187```json
188 "api-cache": {
189 "enabled": true,
190 "max-size": 64,
191 "ttl": "5i"
192 },
193```
194
195In `SETUP.md`, after the `cache` bullet list, add:
196
197```markdown
198* `api-cache` — In-memory cache of DeviantArt API responses. Every page,
199 feed poll and API call that asks DeviantArt the same question within the
200 TTL is answered from memory, and concurrent requests for one thing make
201 one upstream call. On by default; DeviantArt bans egress IPs that ask too
202 often, so leave it on unless you are debugging.
203 * `enabled` — boolean, default true
204 * `max-size` — megabytes of response bodies to hold, default 64. Least
205 recently used entries are dropped past this.
206 * `ttl` — how long a response is reused, in the time units above. Default
207 `5i`.
208```
209
210Also add `d` — days to the Time units list at the top of `SETUP.md`.
211
212- [ ] **Step 4: Run tests**
213
214Run: `go test ./app -count=1 -race`
215Expected: PASS.
216
217- [ ] **Step 5: Commit**
218
219```bash
220git add app/config.go app/config_test.go config.example.json SETUP.md
221git commit -m "Add the api-cache config block and a shared lifetime parser
222
223Ref #8"
224```
225
226---
227
228### Task 2: The cache store and transport
229
230**Files:**
231- Create: `app/apicache.go`, `app/apicache_test.go`
232- Modify: `go.mod`, `go.sum`
233
234**Interfaces:**
235- Consumes: nothing from Task 1 (the store takes its bound and TTL as arguments).
236- Produces: `func newAPICache(maxBytes int64, ttl time.Duration) *apiCache`;
237 `func (c *apiCache) transport(base http.RoundTripper) http.RoundTripper`;
238 `func (c *apiCache) stats() (hits, misses int64, entries int, held int64)`;
239 `func cacheable(req *http.Request) bool`; `func cacheKey(req *http.Request) string`.
240
241- [ ] **Step 1: Add the dependency**
242
243Run: `go get golang.org/x/sync@latest && go mod tidy`
244
245- [ ] **Step 2: Write the failing tests**
246
247```go
248package app
249
250import (
251 "io"
252 "net/http"
253 "strings"
254 "sync"
255 "testing"
256 "time"
257)
258
259// fakeRT is the upstream: it counts calls and returns a scripted response.
260type fakeRT struct {
261 mu sync.Mutex
262 calls int
263 status int
264 body string
265 delay time.Duration
266}
267
268func (f *fakeRT) RoundTrip(r *http.Request) (*http.Response, error) {
269 f.mu.Lock()
270 f.calls++
271 f.mu.Unlock()
272 time.Sleep(f.delay)
273 return &http.Response{
274 StatusCode: f.status,
275 Header: http.Header{"Content-Type": {"application/json"}},
276 Body: io.NopCloser(strings.NewReader(f.body)),
277 Request: r,
278 }, nil
279}
280
281func (f *fakeRT) count() int {
282 f.mu.Lock()
283 defer f.mu.Unlock()
284 return f.calls
285}
286
287const puppyURL = "https://www.deviantart.com/_puppy/dabrowse/search/all?q=fox&csrf_token=abc"
288
289func get(t *testing.T, rt http.RoundTripper, url string) (int, string) {
290 t.Helper()
291 req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:noctx // test request
292 if err != nil {
293 t.Fatal(err)
294 }
295 resp, err := rt.RoundTrip(req)
296 if err != nil {
297 t.Fatal(err)
298 }
299 defer func() { _ = resp.Body.Close() }()
300 body, _ := io.ReadAll(resp.Body)
301 return resp.StatusCode, string(body)
302}
303
304func TestSecondRequestIsServedFromCache(t *testing.T) {
305 up := &fakeRT{status: 200, body: `{"a":1}`}
306 rt := newAPICache(1<<20, time.Minute).transport(up)
307
308 get(t, rt, puppyURL)
309 status, body := get(t, rt, puppyURL)
310
311 if up.count() != 1 {
312 t.Errorf("upstream called %d times, want 1", up.count())
313 }
314 if status != 200 || body != `{"a":1}` {
315 t.Errorf("cached response is %d %q", status, body)
316 }
317}
318
319func TestExpiredEntryIsRefetched(t *testing.T) {
320 up := &fakeRT{status: 200, body: `{}`}
321 c := newAPICache(1<<20, time.Minute)
322 now := time.Now()
323 c.now = func() time.Time { return now }
324 rt := c.transport(up)
325
326 get(t, rt, puppyURL)
327 now = now.Add(2 * time.Minute)
328 get(t, rt, puppyURL)
329
330 if up.count() != 2 {
331 t.Errorf("upstream called %d times, want 2 after expiry", up.count())
332 }
333}
334
335func TestNon200IsNotStored(t *testing.T) {
336 up := &fakeRT{status: 403, body: "blocked"}
337 rt := newAPICache(1<<20, time.Minute).transport(up)
338
339 status, body := get(t, rt, puppyURL)
340 get(t, rt, puppyURL)
341
342 if status != 403 || body != "blocked" {
343 t.Errorf("first response is %d %q, want the upstream 403 passed through", status, body)
344 }
345 if up.count() != 2 {
346 t.Errorf("upstream called %d times, want 2: a 403 must not be cached", up.count())
347 }
348}
349
350func TestBypassesSessionAndOtherHosts(t *testing.T) {
351 up := &fakeRT{status: 200, body: "x"}
352 rt := newAPICache(1<<20, time.Minute).transport(up)
353
354 for _, url := range []string{
355 "https://www.deviantart.com/_puppy",
356 "https://www.deviantart.com",
357 "https://a.deviantart.net/avatars-big/a/alice.png",
358 } {
359 get(t, rt, url)
360 get(t, rt, url)
361 }
362 if up.count() != 6 {
363 t.Errorf("upstream called %d times, want 6: none of these URLs may be cached", up.count())
364 }
365}
366
367func TestKeyIgnoresCSRFToken(t *testing.T) {
368 up := &fakeRT{status: 200, body: "x"}
369 rt := newAPICache(1<<20, time.Minute).transport(up)
370
371 get(t, rt, puppyURL)
372 get(t, rt, strings.Replace(puppyURL, "csrf_token=abc", "csrf_token=def", 1))
373
374 if up.count() != 1 {
375 t.Errorf("upstream called %d times, want 1: a token refresh must not miss", up.count())
376 }
377}
378
379func TestByteBoundEvictsLeastRecentlyUsed(t *testing.T) {
380 up := &fakeRT{status: 200, body: strings.Repeat("x", 100)}
381 rt := newAPICache(250, time.Minute).transport(up)
382 a := "https://www.deviantart.com/_puppy/a?p=1"
383 b := "https://www.deviantart.com/_puppy/b?p=1"
384 c := "https://www.deviantart.com/_puppy/c?p=1"
385
386 get(t, rt, a)
387 get(t, rt, b)
388 get(t, rt, a) // a is now more recent than b
389 get(t, rt, c) // 300 bytes would exceed 250: b goes
390 get(t, rt, a)
391 get(t, rt, c)
392 get(t, rt, b)
393
394 if up.count() != 4 {
395 t.Errorf("upstream called %d times, want 4: only b should have been evicted", up.count())
396 }
397}
398
399func TestConcurrentMissesMakeOneUpstreamCall(t *testing.T) {
400 up := &fakeRT{status: 200, body: "x", delay: 50 * time.Millisecond}
401 rt := newAPICache(1<<20, time.Minute).transport(up)
402
403 var wg sync.WaitGroup
404 for range 20 {
405 wg.Go(func() { get(t, rt, puppyURL) })
406 }
407 wg.Wait()
408
409 if up.count() != 1 {
410 t.Errorf("upstream called %d times, want 1 for a burst on one key", up.count())
411 }
412}
413
414func TestStatsCountHitsAndMisses(t *testing.T) {
415 up := &fakeRT{status: 200, body: "abc"}
416 c := newAPICache(1<<20, time.Minute)
417 rt := c.transport(up)
418
419 get(t, rt, puppyURL)
420 get(t, rt, puppyURL)
421 get(t, rt, puppyURL)
422
423 hits, misses, entries, held := c.stats()
424 if hits != 2 || misses != 1 || entries != 1 || held != 3 {
425 t.Errorf("stats = %d hits, %d misses, %d entries, %d bytes; want 2, 1, 1, 3", hits, misses, entries, held)
426 }
427}
428```
429
430Note: `wg.Go` needs Go 1.25, which `go.mod` already requires.
431
432- [ ] **Step 3: Run to verify it fails**
433
434Run: `go test ./app -run 'Cache|Bypasses|Key|Bound|Concurrent|Stats' -count=1`
435Expected: FAIL, `undefined: newAPICache`.
436
437- [ ] **Step 4: Implement `app/apicache.go`**
438
439```go
440package app
441
442import (
443 "bytes"
444 "container/list"
445 "io"
446 "net/http"
447 "strings"
448 "sync"
449 "time"
450
451 "golang.org/x/sync/singleflight"
452)
453
454// apiCache holds DeviantArt API responses so that repeat and concurrent
455// requests for one URL cost one upstream call. It sits in front of the
456// throttle: a hit never touches DeviantArt or the throttle's budget.
457//
458// The store is bounded by body bytes with least-recently-used eviction. It
459// is safe for concurrent use.
460type apiCache struct {
461 maxBytes int64
462 ttl time.Duration
463 now func() time.Time
464
465 mu sync.Mutex
466 entries map[string]*cacheEntry
467 lru *list.List // front is most recently used
468 held int64
469 hits int64
470 misses int64
471
472 flight singleflight.Group
473}
474
475// cacheEntry is one buffered response. header is a clone of the upstream
476// header; body is the whole body, read once.
477type cacheEntry struct {
478 key string
479 status int
480 header http.Header
481 body []byte
482 expires time.Time
483 elem *list.Element
484}
485
486func newAPICache(maxBytes int64, ttl time.Duration) *apiCache {
487 return &apiCache{
488 maxBytes: maxBytes,
489 ttl: ttl,
490 now: time.Now,
491 entries: map[string]*cacheEntry{},
492 lru: list.New(),
493 }
494}
495
496// cacheable reports whether a request is one the cache handles: a GET to
497// DeviantArt's API or its group search page. The session bootstrap (/_puppy
498// with no path), the homepage, avatars and media all pass through.
499func cacheable(req *http.Request) bool {
500 if req.Method != http.MethodGet || req.URL.Host != "www.deviantart.com" {
501 return false
502 }
503 p := req.URL.Path
504 return (strings.HasPrefix(p, "/_puppy/") && len(p) > len("/_puppy/")) ||
505 strings.HasPrefix(p, "/groups/")
506}
507
508// cacheKey is the URL without csrf_token, which changes every twelve hours
509// and would otherwise empty the cache on each refresh.
510func cacheKey(req *http.Request) string {
511 u := *req.URL
512 q := u.Query()
513 q.Del("csrf_token")
514 u.RawQuery = q.Encode()
515 return u.String()
516}
517
518// transport returns a RoundTripper that answers from this cache and sends
519// misses to base. Several transports may share one cache.
520func (c *apiCache) transport(base http.RoundTripper) http.RoundTripper {
521 return &cachedTransport{cache: c, base: base}
522}
523
524type cachedTransport struct {
525 cache *apiCache
526 base http.RoundTripper
527}
528
529// RoundTrip serves a hit from memory. A miss is fetched once per key however
530// many callers are waiting, buffered, stored if it is a 200, and handed to
531// every waiter as its own response.
532func (t *cachedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
533 if !cacheable(req) {
534 return t.base.RoundTrip(req)
535 }
536 key := cacheKey(req)
537 if e := t.cache.get(key); e != nil {
538 return e.response(req), nil
539 }
540
541 v, err, _ := t.cache.flight.Do(key, func() (any, error) {
542 resp, err := t.base.RoundTrip(req)
543 if err != nil {
544 return nil, err
545 }
546 defer func() { _ = resp.Body.Close() }()
547 body, err := io.ReadAll(resp.Body)
548 if err != nil {
549 return nil, err
550 }
551 e := &cacheEntry{key: key, status: resp.StatusCode, header: resp.Header.Clone(), body: body}
552 if e.status == http.StatusOK {
553 t.cache.put(e)
554 }
555 return e, nil
556 })
557 if err != nil {
558 return nil, err
559 }
560 e, ok := v.(*cacheEntry)
561 if !ok {
562 return nil, io.ErrUnexpectedEOF
563 }
564 return e.response(req), nil
565}
566
567// response builds a fresh http.Response over the buffered body, so each
568// caller can read and close its own.
569func (e *cacheEntry) response(req *http.Request) *http.Response {
570 return &http.Response{
571 Status: http.StatusText(e.status),
572 StatusCode: e.status,
573 Proto: "HTTP/1.1",
574 ProtoMajor: 1,
575 ProtoMinor: 1,
576 Header: e.header.Clone(),
577 Body: io.NopCloser(bytes.NewReader(e.body)),
578 ContentLength: int64(len(e.body)),
579 Request: req,
580 }
581}
582
583// get returns the live entry for key, marking it most recently used, or nil.
584// An expired entry is dropped on the way out.
585func (c *apiCache) get(key string) *cacheEntry {
586 c.mu.Lock()
587 defer c.mu.Unlock()
588
589 e := c.entries[key]
590 if e == nil {
591 c.misses++
592 return nil
593 }
594 if !c.now().Before(e.expires) {
595 c.remove(e)
596 c.misses++
597 return nil
598 }
599 c.lru.MoveToFront(e.elem)
600 c.hits++
601 return e
602}
603
604// put stores e, evicting from the least recently used end until it fits. A
605// body larger than the whole bound is not stored.
606func (c *apiCache) put(e *cacheEntry) {
607 size := int64(len(e.body))
608 if size > c.maxBytes {
609 return
610 }
611
612 c.mu.Lock()
613 defer c.mu.Unlock()
614
615 if old := c.entries[e.key]; old != nil {
616 c.remove(old)
617 }
618 for c.held+size > c.maxBytes {
619 back, ok := c.lru.Back().Value.(*cacheEntry)
620 if !ok {
621 break
622 }
623 c.remove(back)
624 }
625 e.expires = c.now().Add(c.ttl)
626 e.elem = c.lru.PushFront(e)
627 c.entries[e.key] = e
628 c.held += size
629}
630
631// remove drops e. The caller holds mu.
632func (c *apiCache) remove(e *cacheEntry) {
633 c.lru.Remove(e.elem)
634 delete(c.entries, e.key)
635 c.held -= int64(len(e.body))
636}
637
638// stats reports the counters for the hourly log line.
639func (c *apiCache) stats() (hits, misses int64, entries int, held int64) {
640 c.mu.Lock()
641 defer c.mu.Unlock()
642 return c.hits, c.misses, len(c.entries), c.held
643}
644```
645
646- [ ] **Step 5: Run tests**
647
648Run: `go test ./app -count=1 -race`
649Expected: PASS.
650
651- [ ] **Step 6: Lint**
652
653Run: `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./...`
654Expected: `0 issues.`
655
656- [ ] **Step 7: Commit**
657
658```bash
659git add app/apicache.go app/apicache_test.go go.mod go.sum
660git commit -m "Add an in-memory cache for DeviantArt API responses
661
662Ref #8"
663```
664
665---
666
667### Task 3: Install the cache in the transport chain
668
669**Files:**
670- Modify: `app/httpclient.go`
671- Modify: `app/apicache_test.go` (one more test)
672- Modify: `app/httpclient_test.go` if it constructs the chain directly (read it first)
673
674**Interfaces:**
675- Consumes: `newAPICache`, `(*apiCache).transport`, `(*apiCache).stats`,
676 `CFG.APICache`, `apiCacheTTL`, `throttled`, `daThrottle`.
677- Produces: package var `daCache *apiCache` (nil when disabled);
678 `func chain(base http.RoundTripper) http.RoundTripper`.
679
680- [ ] **Step 1: Write the failing test** (append to `app/apicache_test.go`)
681
682```go
683// TestHitDoesNotConsumeAThrottleSlot pins the chain order: the cache sits in
684// front of the throttle, so a hit returns even when every throttle slot is
685// held. With the order reversed this test hangs and times out.
686func TestHitDoesNotConsumeAThrottleSlot(t *testing.T) {
687 up := &fakeRT{status: 200, body: "x"}
688 th := &daThrottle{base: up, sem: make(chan struct{}, 1)}
689 rt := newAPICache(1<<20, time.Minute).transport(th)
690
691 get(t, rt, puppyURL) // populate through the throttle
692
693 th.sem <- struct{}{} // hold the only slot
694 done := make(chan struct{})
695 go func() {
696 get(t, rt, puppyURL)
697 close(done)
698 }()
699 select {
700 case <-done:
701 case <-time.After(2 * time.Second):
702 t.Fatal("a cache hit waited on the throttle")
703 }
704}
705```
706
707- [ ] **Step 2: Run to verify it fails**
708
709Run: `go test ./app -run TestHitDoesNotConsumeAThrottleSlot -count=1`
710Expected: PASS already, since the test builds the chain by hand. That is
711fine: it documents the required order, and Step 3 makes the production
712chain match it.
713
714- [ ] **Step 3: Implement**
715
716In `app/httpclient.go`, after `var baseTransport *http.Transport`:
717
718```go
719// daCache is the API response cache shared by every transport, or nil when
720// api-cache.enabled is false.
721var daCache *apiCache
722
723// chain wraps base with the throttle and, when enabled, the cache in front
724// of it, so a hit never spends a throttle slot.
725func chain(base http.RoundTripper) http.RoundTripper {
726 rt := throttled(base)
727 if daCache != nil {
728 return daCache.transport(rt)
729 }
730 return rt
731}
732
733// logCacheStatsForever prints one line an hour so an operator can see the
734// cache working without an endpoint. Run it in its own goroutine.
735func logCacheStatsForever(c *apiCache) {
736 for {
737 time.Sleep(time.Hour)
738 hits, misses, entries, held := c.stats()
739 println("api cache:", hits, "hits,", misses, "misses,", entries, "entries,", held>>20, "MB held")
740 }
741}
742```
743
744Change `InstallDAThrottle` to:
745
746```go
747func InstallDAThrottle() {
748 baseTransport = tunedTransport()
749 if CFG.APICache.Enabled {
750 daCache = newAPICache(CFG.APICache.MaxSize<<20, apiCacheTTL)
751 go logCacheStatsForever(daCache)
752 }
753 http.DefaultTransport = chain(baseTransport)
754}
755```
756
757Update its doc comment to mention the cache. In `ProxiedTransport`,
758replace `return throttled(base)` with `return chain(base)`.
759
760- [ ] **Step 4: Run tests and lint**
761
762Run: `go test ./... -count=1 -race && go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./...`
763Expected: PASS, `0 issues.`
764
765- [ ] **Step 5: Commit**
766
767```bash
768git add app/httpclient.go app/apicache_test.go
769git commit -m "Serve DeviantArt API responses from the cache
770
771The cache sits in front of the throttle, so a hit costs neither an
772upstream request nor a throttle slot. On by default; api-cache.enabled
773turns it off.
774
775Closes #8"
776```
docs/superpowers/specs/2026-09-10-api-cache-design.md added +117
@@ -0,0 +1,117 @@
1# API response cache
2
3Issue #8. Roadmap item 1.1.
4
5## Problem
6
7Nothing from DeviantArt's JSON API is cached. Every page view re-fetches
8its JSON, concurrent requests for one page each go upstream, and feed
9readers and `/api/random` add calls on a schedule. DeviantArt fronts the
10API with a WAF that bans egress IPs, so the number of upstream requests,
11not their pacing, is what gets an instance blocked. The throttle in
12`app/httpclient.go` spaces requests out; it does not reduce them.
13
14## Placement
15
16A caching `http.RoundTripper` in `app/apicache.go`. `InstallDAThrottle`
17builds the chain
18
19 devianter -> cache -> throttle -> base transport
20
21so a hit is answered before the throttle's rate limiter and semaphore are
22consulted, and a miss goes through them as today. `ProxiedTransport`
23builds the same chain over its proxied base, sharing the one cache
24instance.
25
26## Scope
27
28Cached: GET requests to host `www.deviantart.com` whose path starts with
29`/_puppy/` (with something after it) or `/groups/`, when the response
30status is 200.
31
32Passed through, never stored: every other method, host or path. That
33includes the session bootstrap (`/_puppy` bare), the homepage CSRF scrape,
34avatars and emotes on `a.deviantart.net` and `e.deviantart.net` (issue #9),
35and wixmp media. Non-200 responses and transport errors are returned
36unchanged and not stored, so a WAF block is not remembered.
37
38## Key
39
40The request URL with the `csrf_token` query parameter removed, otherwise
41verbatim. The token changes every twelve hours and would otherwise empty
42the cache on each refresh. The guest cookie is the same for every request
43and is not part of the key.
44
45## Entry
46
47Status, `Content-Type`, body bytes, expiry time. A hit returns a new
48`*http.Response` with those headers, `ContentLength` set, and the body as
49a `bytes.Reader`. devianter reads the body and closes it as with a live
50response.
51
52## Coalescing
53
54`golang.org/x/sync/singleflight` keyed the same as the cache. Concurrent
55misses for one key make one upstream request; every waiter receives the
56same stored entry. A miss whose upstream result is not cacheable is still
57shared with the waiters of that flight, then not stored.
58
59## Bounds
60
61Bounded by total body bytes. Least-recently-used eviction over a map plus
62`container/list`, one mutex. A lookup that finds an expired entry removes
63it and reports a miss. An insert that would exceed the bound evicts from
64the least recently used end until it fits. A body larger than the bound is
65served but not stored. No background goroutine.
66
67## Configuration
68
69New block in `config.json`, beside `cache`:
70
71 "api-cache": {
72 "enabled": true,
73 "max-size": 64,
74 "ttl": "5i"
75 }
76
77`enabled` defaults to true. `max-size` is megabytes, default 64. `ttl`
78uses the same unit syntax as `cache.lifetime` (`i` minutes, `h` hours,
79`d` days, `w` weeks, `m` months, `y` years), default five minutes. The
80lifetime parser in `config.go` becomes a function both blocks call; an
81unparseable value exits at startup with the same message as today.
82
83One TTL for every endpoint. Per-endpoint values are not in scope.
84
85## Observability
86
87Every hour, one line on stdout: hits, misses, entries, bytes held. Nothing
88else. No endpoint.
89
90## Errors
91
92Upstream errors and non-200 statuses pass through unchanged. A body read
93error is returned as an error to the caller and nothing is stored.
94
95## Testing
96
97`app/apicache_test.go`, with a fake base `RoundTripper` that counts calls
98and returns scripted responses:
99
100- second request for one key makes no upstream call
101- a request after expiry refetches
102- a non-200 response is not stored; the next request refetches
103- `/_puppy` bare, the homepage, and a `.net` host bypass the cache
104- two URLs differing only in `csrf_token` share one entry
105- inserting past `max-size` evicts the least recently used entry
106- a burst of concurrent misses for one key produces one upstream call
107- a hit through the full `cached(throttled(base))` chain does not consume
108 a throttle slot: hold the semaphore full, make the hit, observe it
109 return
110
111## Files
112
113- new `app/apicache.go`, `app/apicache_test.go`
114- `app/httpclient.go`: build the chain
115- `app/config.go`: the block, defaults, shared lifetime parser
116- `config.example.json`, `SETUP.md`
117- `go.mod`, `go.sum`: `golang.org/x/sync`
go.mod +3 −1
@@ -1,8 +1,10 @@
11module skunkyart
22
3go 1.25.0
3go 1.26.0
44
55require (
66 github.com/krazywarez/devianter v0.3.4
77 golang.org/x/net v0.58.0
88)
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
22github.com/krazywarez/devianter v0.3.4/go.mod h1:d+0cnLQqQNoiuVCuCMxvox3tLNj3n2vEWv7bqVn+2s8=
33golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
44golang.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=