docs/superpowers/plans/2026-09-10-api-cache.md
776 lines · 20499 bytes
API Response Cache Implementation Plan
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.
Goal: Cache DeviantArt API responses in memory so repeat and concurrent page views make one upstream request instead of many.
Architecture: A caching http.RoundTripper sits in front of the
existing throttle. It stores 200 responses for /_puppy/ and /groups/
GETs keyed by URL minus csrf_token, bounded by bytes with LRU eviction,
with singleflight coalescing of concurrent misses.
Tech Stack: Go 1.25, golang.org/x/sync/singleflight, standard
library container/list.
Spec: docs/superpowers/specs/2026-09-10-api-cache-design.md
Global Constraints
- Go module is
skunkyart; package under test isapp. - Lint must stay clean:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./.... - Tests run with
go test ./app -count=1 -race. - No attribution trailers in commits.
- Errors and log lines are plain sentences, lowercase error strings.
Task 1: Shared lifetime parser and the api-cache config block
Files:
- Modify:
app/config.go - Create:
app/config_test.go - Modify:
config.example.json,SETUP.md
Interfaces:
-
Produces:
func parseLifetime(s string) (time.Duration, error);CFG.APICacheof typeapiCacheConfig{Enabled bool; MaxSize int64; TTL string}; package varapiCacheTTL time.Durationset byExecuteConfig. -
Step 1: Write the failing tests
package app
import (
"testing"
"time"
)
func TestParseLifetimeUnits(t *testing.T) {
cases := map[string]time.Duration{
"5i": 5 * time.Minute,
"2h": 2 * time.Hour,
"3d": 72 * time.Hour,
"1w": 7 * 24 * time.Hour,
"1m": 30 * 24 * time.Hour,
"1y": 360 * 24 * time.Hour,
"12i": 12 * time.Minute,
}
for in, want := range cases {
got, err := parseLifetime(in)
if err != nil || got != want {
t.Errorf("parseLifetime(%q) = %v, %v; want %v", in, got, err, want)
}
}
}
func TestParseLifetimeRejectsBadInput(t *testing.T) {
for _, in := range []string{"", "5", "5x", "h"} {
if _, err := parseLifetime(in); err == nil {
t.Errorf("parseLifetime(%q) accepted, want an error", in)
}
}
}
func TestAPICacheDefaults(t *testing.T) {
if !CFG.APICache.Enabled || CFG.APICache.MaxSize != 64 || CFG.APICache.TTL != "5i" {
t.Errorf("defaults are %+v, want enabled, 64 MB, 5i", CFG.APICache)
}
}
- Step 2: Run to verify it fails
Run: go test ./app -run 'TestParseLifetime|TestAPICacheDefaults' -count=1
Expected: FAIL, undefined: parseLifetime.
- Step 3: Implement
In app/config.go, add "errors" to the imports. Add the type and the
field:
type apiCacheConfig struct {
Enabled bool `json:"enabled"`
MaxSize int64 `json:"max-size"`
TTL string `json:"ttl"`
}
In config, after Cache:
APICache apiCacheConfig `json:"api-cache"`
In the CFG literal, after the Cache: block:
APICache: apiCacheConfig{
Enabled: true,
MaxSize: 64,
TTL: "5i",
},
After var lifetimeParsed int64:
// apiCacheTTL is api-cache.ttl parsed, set by ExecuteConfig.
var apiCacheTTL time.Duration
// parseLifetime reads a duration in the config's unit syntax: a number
// followed by i (minutes), h (hours), d (days), w (weeks), m (30-day
// months) or y (360-day years).
func parseLifetime(s string) (time.Duration, error) {
if s == "" {
return 0, errors.New("empty lifetime")
}
numstr := regexp.MustCompile("[0-9]+").FindAllString(s, -1)
if len(numstr) == 0 {
return 0, errors.New("lifetime has no number: " + s)
}
num, _ := strconv.Atoi(numstr[len(numstr)-1])
day := 24 * time.Hour
var unit time.Duration
switch s[len(s)-1:] {
case "i":
unit = time.Minute
case "h":
unit = time.Hour
case "d":
unit = day
case "w":
unit = 7 * day
case "m":
unit = 30 * day
case "y":
unit = 360 * day
default:
return 0, errors.New("invalid unit specified: " + s[len(s)-1:])
}
return unit * time.Duration(num), nil
}
Replace the if CFG.Cache.Lifetime != "" { ... } block inside
ExecuteConfig with:
if CFG.Cache.Lifetime != "" {
d, err := parseLifetime(CFG.Cache.Lifetime)
if err != nil {
exit("config: cache.lifetime: "+err.Error(), 1)
}
lifetimeParsed = d.Milliseconds()
}
After the theme switch, before static.StaticPath = CFG.StaticPath:
if CFG.APICache.Enabled {
d, err := parseLifetime(CFG.APICache.TTL)
if err != nil {
exit("config: api-cache.ttl: "+err.Error(), 1)
}
apiCacheTTL = d
}
Remove the now-unused day and duration locals from ExecuteConfig.
In config.example.json, after the cache block:
"api-cache": {
"enabled": true,
"max-size": 64,
"ttl": "5i"
},
In SETUP.md, after the cache bullet list, add:
* `api-cache` — In-memory cache of DeviantArt API responses. Every page,
feed poll and API call that asks DeviantArt the same question within the
TTL is answered from memory, and concurrent requests for one thing make
one upstream call. On by default; DeviantArt bans egress IPs that ask too
often, so leave it on unless you are debugging.
* `enabled` — boolean, default true
* `max-size` — megabytes of response bodies to hold, default 64. Least
recently used entries are dropped past this.
* `ttl` — how long a response is reused, in the time units above. Default
`5i`.
Also add d — days to the Time units list at the top of SETUP.md.
- Step 4: Run tests
Run: go test ./app -count=1 -race
Expected: PASS.
- Step 5: Commit
git add app/config.go app/config_test.go config.example.json SETUP.md
git commit -m "Add the api-cache config block and a shared lifetime parser
Ref #8"
Task 2: The cache store and transport
Files:
- Create:
app/apicache.go,app/apicache_test.go - Modify:
go.mod,go.sum
Interfaces:
-
Consumes: nothing from Task 1 (the store takes its bound and TTL as arguments).
-
Produces:
func newAPICache(maxBytes int64, ttl time.Duration) *apiCache;func (c *apiCache) transport(base http.RoundTripper) http.RoundTripper;func (c *apiCache) stats() (hits, misses int64, entries int, held int64);func cacheable(req *http.Request) bool;func cacheKey(req *http.Request) string. -
Step 1: Add the dependency
Run: go get golang.org/x/sync@latest && go mod tidy
- Step 2: Write the failing tests
package app
import (
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
)
// fakeRT is the upstream: it counts calls and returns a scripted response.
type fakeRT struct {
mu sync.Mutex
calls int
status int
body string
delay time.Duration
}
func (f *fakeRT) RoundTrip(r *http.Request) (*http.Response, error) {
f.mu.Lock()
f.calls++
f.mu.Unlock()
time.Sleep(f.delay)
return &http.Response{
StatusCode: f.status,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(f.body)),
Request: r,
}, nil
}
func (f *fakeRT) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}
const puppyURL = "https://www.deviantart.com/_puppy/dabrowse/search/all?q=fox&csrf_token=abc"
func get(t *testing.T, rt http.RoundTripper, url string) (int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:noctx // test request
if err != nil {
t.Fatal(err)
}
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body)
}
func TestSecondRequestIsServedFromCache(t *testing.T) {
up := &fakeRT{status: 200, body: `{"a":1}`}
rt := newAPICache(1<<20, time.Minute).transport(up)
get(t, rt, puppyURL)
status, body := get(t, rt, puppyURL)
if up.count() != 1 {
t.Errorf("upstream called %d times, want 1", up.count())
}
if status != 200 || body != `{"a":1}` {
t.Errorf("cached response is %d %q", status, body)
}
}
func TestExpiredEntryIsRefetched(t *testing.T) {
up := &fakeRT{status: 200, body: `{}`}
c := newAPICache(1<<20, time.Minute)
now := time.Now()
c.now = func() time.Time { return now }
rt := c.transport(up)
get(t, rt, puppyURL)
now = now.Add(2 * time.Minute)
get(t, rt, puppyURL)
if up.count() != 2 {
t.Errorf("upstream called %d times, want 2 after expiry", up.count())
}
}
func TestNon200IsNotStored(t *testing.T) {
up := &fakeRT{status: 403, body: "blocked"}
rt := newAPICache(1<<20, time.Minute).transport(up)
status, body := get(t, rt, puppyURL)
get(t, rt, puppyURL)
if status != 403 || body != "blocked" {
t.Errorf("first response is %d %q, want the upstream 403 passed through", status, body)
}
if up.count() != 2 {
t.Errorf("upstream called %d times, want 2: a 403 must not be cached", up.count())
}
}
func TestBypassesSessionAndOtherHosts(t *testing.T) {
up := &fakeRT{status: 200, body: "x"}
rt := newAPICache(1<<20, time.Minute).transport(up)
for _, url := range []string{
"https://www.deviantart.com/_puppy",
"https://www.deviantart.com",
"https://a.deviantart.net/avatars-big/a/alice.png",
} {
get(t, rt, url)
get(t, rt, url)
}
if up.count() != 6 {
t.Errorf("upstream called %d times, want 6: none of these URLs may be cached", up.count())
}
}
func TestKeyIgnoresCSRFToken(t *testing.T) {
up := &fakeRT{status: 200, body: "x"}
rt := newAPICache(1<<20, time.Minute).transport(up)
get(t, rt, puppyURL)
get(t, rt, strings.Replace(puppyURL, "csrf_token=abc", "csrf_token=def", 1))
if up.count() != 1 {
t.Errorf("upstream called %d times, want 1: a token refresh must not miss", up.count())
}
}
func TestByteBoundEvictsLeastRecentlyUsed(t *testing.T) {
up := &fakeRT{status: 200, body: strings.Repeat("x", 100)}
rt := newAPICache(250, time.Minute).transport(up)
a := "https://www.deviantart.com/_puppy/a?p=1"
b := "https://www.deviantart.com/_puppy/b?p=1"
c := "https://www.deviantart.com/_puppy/c?p=1"
get(t, rt, a)
get(t, rt, b)
get(t, rt, a) // a is now more recent than b
get(t, rt, c) // 300 bytes would exceed 250: b goes
get(t, rt, a)
get(t, rt, c)
get(t, rt, b)
if up.count() != 4 {
t.Errorf("upstream called %d times, want 4: only b should have been evicted", up.count())
}
}
func TestConcurrentMissesMakeOneUpstreamCall(t *testing.T) {
up := &fakeRT{status: 200, body: "x", delay: 50 * time.Millisecond}
rt := newAPICache(1<<20, time.Minute).transport(up)
var wg sync.WaitGroup
for range 20 {
wg.Go(func() { get(t, rt, puppyURL) })
}
wg.Wait()
if up.count() != 1 {
t.Errorf("upstream called %d times, want 1 for a burst on one key", up.count())
}
}
func TestStatsCountHitsAndMisses(t *testing.T) {
up := &fakeRT{status: 200, body: "abc"}
c := newAPICache(1<<20, time.Minute)
rt := c.transport(up)
get(t, rt, puppyURL)
get(t, rt, puppyURL)
get(t, rt, puppyURL)
hits, misses, entries, held := c.stats()
if hits != 2 || misses != 1 || entries != 1 || held != 3 {
t.Errorf("stats = %d hits, %d misses, %d entries, %d bytes; want 2, 1, 1, 3", hits, misses, entries, held)
}
}
Note: wg.Go needs Go 1.25, which go.mod already requires.
- Step 3: Run to verify it fails
Run: go test ./app -run 'Cache|Bypasses|Key|Bound|Concurrent|Stats' -count=1
Expected: FAIL, undefined: newAPICache.
- Step 4: Implement
app/apicache.go
package app
import (
"bytes"
"container/list"
"io"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
// apiCache holds DeviantArt API responses so that repeat and concurrent
// requests for one URL cost one upstream call. It sits in front of the
// throttle: a hit never touches DeviantArt or the throttle's budget.
//
// The store is bounded by body bytes with least-recently-used eviction. It
// is safe for concurrent use.
type apiCache struct {
maxBytes int64
ttl time.Duration
now func() time.Time
mu sync.Mutex
entries map[string]*cacheEntry
lru *list.List // front is most recently used
held int64
hits int64
misses int64
flight singleflight.Group
}
// cacheEntry is one buffered response. header is a clone of the upstream
// header; body is the whole body, read once.
type cacheEntry struct {
key string
status int
header http.Header
body []byte
expires time.Time
elem *list.Element
}
func newAPICache(maxBytes int64, ttl time.Duration) *apiCache {
return &apiCache{
maxBytes: maxBytes,
ttl: ttl,
now: time.Now,
entries: map[string]*cacheEntry{},
lru: list.New(),
}
}
// cacheable reports whether a request is one the cache handles: a GET to
// DeviantArt's API or its group search page. The session bootstrap (/_puppy
// with no path), the homepage, avatars and media all pass through.
func cacheable(req *http.Request) bool {
if req.Method != http.MethodGet || req.URL.Host != "www.deviantart.com" {
return false
}
p := req.URL.Path
return (strings.HasPrefix(p, "/_puppy/") && len(p) > len("/_puppy/")) ||
strings.HasPrefix(p, "/groups/")
}
// cacheKey is the URL without csrf_token, which changes every twelve hours
// and would otherwise empty the cache on each refresh.
func cacheKey(req *http.Request) string {
u := *req.URL
q := u.Query()
q.Del("csrf_token")
u.RawQuery = q.Encode()
return u.String()
}
// transport returns a RoundTripper that answers from this cache and sends
// misses to base. Several transports may share one cache.
func (c *apiCache) transport(base http.RoundTripper) http.RoundTripper {
return &cachedTransport{cache: c, base: base}
}
type cachedTransport struct {
cache *apiCache
base http.RoundTripper
}
// RoundTrip serves a hit from memory. A miss is fetched once per key however
// many callers are waiting, buffered, stored if it is a 200, and handed to
// every waiter as its own response.
func (t *cachedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !cacheable(req) {
return t.base.RoundTrip(req)
}
key := cacheKey(req)
if e := t.cache.get(key); e != nil {
return e.response(req), nil
}
v, err, _ := t.cache.flight.Do(key, func() (any, error) {
resp, err := t.base.RoundTrip(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
e := &cacheEntry{key: key, status: resp.StatusCode, header: resp.Header.Clone(), body: body}
if e.status == http.StatusOK {
t.cache.put(e)
}
return e, nil
})
if err != nil {
return nil, err
}
e, ok := v.(*cacheEntry)
if !ok {
return nil, io.ErrUnexpectedEOF
}
return e.response(req), nil
}
// response builds a fresh http.Response over the buffered body, so each
// caller can read and close its own.
func (e *cacheEntry) response(req *http.Request) *http.Response {
return &http.Response{
Status: http.StatusText(e.status),
StatusCode: e.status,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: e.header.Clone(),
Body: io.NopCloser(bytes.NewReader(e.body)),
ContentLength: int64(len(e.body)),
Request: req,
}
}
// get returns the live entry for key, marking it most recently used, or nil.
// An expired entry is dropped on the way out.
func (c *apiCache) get(key string) *cacheEntry {
c.mu.Lock()
defer c.mu.Unlock()
e := c.entries[key]
if e == nil {
c.misses++
return nil
}
if !c.now().Before(e.expires) {
c.remove(e)
c.misses++
return nil
}
c.lru.MoveToFront(e.elem)
c.hits++
return e
}
// put stores e, evicting from the least recently used end until it fits. A
// body larger than the whole bound is not stored.
func (c *apiCache) put(e *cacheEntry) {
size := int64(len(e.body))
if size > c.maxBytes {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if old := c.entries[e.key]; old != nil {
c.remove(old)
}
for c.held+size > c.maxBytes {
back, ok := c.lru.Back().Value.(*cacheEntry)
if !ok {
break
}
c.remove(back)
}
e.expires = c.now().Add(c.ttl)
e.elem = c.lru.PushFront(e)
c.entries[e.key] = e
c.held += size
}
// remove drops e. The caller holds mu.
func (c *apiCache) remove(e *cacheEntry) {
c.lru.Remove(e.elem)
delete(c.entries, e.key)
c.held -= int64(len(e.body))
}
// stats reports the counters for the hourly log line.
func (c *apiCache) stats() (hits, misses int64, entries int, held int64) {
c.mu.Lock()
defer c.mu.Unlock()
return c.hits, c.misses, len(c.entries), c.held
}
- Step 5: Run tests
Run: go test ./app -count=1 -race
Expected: PASS.
- Step 6: Lint
Run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./...
Expected: 0 issues.
- Step 7: Commit
git add app/apicache.go app/apicache_test.go go.mod go.sum
git commit -m "Add an in-memory cache for DeviantArt API responses
Ref #8"
Task 3: Install the cache in the transport chain
Files:
- Modify:
app/httpclient.go - Modify:
app/apicache_test.go(one more test) - Modify:
app/httpclient_test.goif it constructs the chain directly (read it first)
Interfaces:
-
Consumes:
newAPICache,(*apiCache).transport,(*apiCache).stats,CFG.APICache,apiCacheTTL,throttled,daThrottle. -
Produces: package var
daCache *apiCache(nil when disabled);func chain(base http.RoundTripper) http.RoundTripper. -
Step 1: Write the failing test (append to
app/apicache_test.go)
// TestHitDoesNotConsumeAThrottleSlot pins the chain order: the cache sits in
// front of the throttle, so a hit returns even when every throttle slot is
// held. With the order reversed this test hangs and times out.
func TestHitDoesNotConsumeAThrottleSlot(t *testing.T) {
up := &fakeRT{status: 200, body: "x"}
th := &daThrottle{base: up, sem: make(chan struct{}, 1)}
rt := newAPICache(1<<20, time.Minute).transport(th)
get(t, rt, puppyURL) // populate through the throttle
th.sem <- struct{}{} // hold the only slot
done := make(chan struct{})
go func() {
get(t, rt, puppyURL)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("a cache hit waited on the throttle")
}
}
- Step 2: Run to verify it fails
Run: go test ./app -run TestHitDoesNotConsumeAThrottleSlot -count=1
Expected: PASS already, since the test builds the chain by hand. That is
fine: it documents the required order, and Step 3 makes the production
chain match it.
- Step 3: Implement
In app/httpclient.go, after var baseTransport *http.Transport:
// daCache is the API response cache shared by every transport, or nil when
// api-cache.enabled is false.
var daCache *apiCache
// chain wraps base with the throttle and, when enabled, the cache in front
// of it, so a hit never spends a throttle slot.
func chain(base http.RoundTripper) http.RoundTripper {
rt := throttled(base)
if daCache != nil {
return daCache.transport(rt)
}
return rt
}
// logCacheStatsForever prints one line an hour so an operator can see the
// cache working without an endpoint. Run it in its own goroutine.
func logCacheStatsForever(c *apiCache) {
for {
time.Sleep(time.Hour)
hits, misses, entries, held := c.stats()
println("api cache:", hits, "hits,", misses, "misses,", entries, "entries,", held>>20, "MB held")
}
}
Change InstallDAThrottle to:
func InstallDAThrottle() {
baseTransport = tunedTransport()
if CFG.APICache.Enabled {
daCache = newAPICache(CFG.APICache.MaxSize<<20, apiCacheTTL)
go logCacheStatsForever(daCache)
}
http.DefaultTransport = chain(baseTransport)
}
Update its doc comment to mention the cache. In ProxiedTransport,
replace return throttled(base) with return chain(base).
- Step 4: Run tests and lint
Run: go test ./... -count=1 -race && go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./...
Expected: PASS, 0 issues.
- Step 5: Commit
git add app/httpclient.go app/apicache_test.go
git commit -m "Serve DeviantArt API responses from the cache
The cache sits in front of the throttle, so a hit costs neither an
upstream request nor a throttle slot. On by default; api-cache.enabled
turns it off.
Closes #8"