Cache DeviantArt API responses in memory !8
13 files changed, +1479 −46
Layout: unified · split
.mailmap deleted −4
| @@ -1,4 +0,0 @@ | |||
| 1 | lost+skunk <skunky@ebloid.ru> <skunky@macaw.me> | ||
| 2 | lost+skunk <skunky@ebloid.ru> <me@lost-skunk.cc> | ||
| 3 | lost+skunk <skunky@ebloid.ru> <skunky@noreply.git.macaw.me> | ||
| 4 | lost+skunk <skunky@ebloid.ru> skunky <skunky@ebloid.ru> | ||
SETUP.md +11
| @@ -3,6 +3,7 @@ Maximum file size in megabytes, requires numeric value.<br> | |||
| 3 | Time units: | 3 | Time units: |
| 4 | * `i` — minutes | 4 | * `i` — minutes |
| 5 | * `h` — hours | 5 | * `h` — hours |
| 6 | * `d` — days | ||
| 6 | * `w` — weeks | 7 | * `w` — weeks |
| 7 | * `m` — months | 8 | * `m` — months |
| 8 | * `y` — years | 9 | * `y` — years |
| @@ -23,6 +24,16 @@ Time units: | |||
| 23 | * `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details) | 24 | * `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details) |
| 24 | * `max-size` — Maximum file size in megabytes | 25 | * `max-size` — Maximum file size in megabytes |
| 25 | * `update-interval` — Automatic rotation interval | 26 | * `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`. | ||
| 26 | * `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. | 37 | * `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. |
| 27 | * `download-proxy` — Outbound proxy used when fetching media from DeviantArt's | 38 | * `download-proxy` — Outbound proxy used when fetching media from DeviantArt's |
| 28 | CDN. Leave empty (`""`) unless you actually run a proxy: if this points at | 39 | CDN. Leave empty (`""`) unless you actually run a proxy: if this points at |
app/apicache.go added +204
| @@ -0,0 +1,204 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 21 | type 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. | ||
| 38 | type 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 | |||
| 47 | func 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. | ||
| 60 | func 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. | ||
| 71 | func 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. | ||
| 81 | func (c *apiCache) transport(base http.RoundTripper) http.RoundTripper { | ||
| 82 | return &cachedTransport{cache: c, base: base} | ||
| 83 | } | ||
| 84 | |||
| 85 | type 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. | ||
| 93 | func (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. | ||
| 130 | func (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. | ||
| 146 | func (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. | ||
| 167 | func (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. | ||
| 193 | func (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. | ||
| 200 | func (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 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 13 | type fakeRT struct { | ||
| 14 | mu sync.Mutex | ||
| 15 | calls int | ||
| 16 | status int | ||
| 17 | body string | ||
| 18 | delay time.Duration | ||
| 19 | } | ||
| 20 | |||
| 21 | func (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 | |||
| 34 | func (f *fakeRT) count() int { | ||
| 35 | f.mu.Lock() | ||
| 36 | defer f.mu.Unlock() | ||
| 37 | return f.calls | ||
| 38 | } | ||
| 39 | |||
| 40 | const puppyURL = "https://www.deviantart.com/_puppy/dabrowse/search/all?q=fox&csrf_token=abc" | ||
| 41 | |||
| 42 | func 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 | |||
| 57 | func 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 | |||
| 72 | func 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 | |||
| 88 | func 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 | |||
| 103 | func 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 | |||
| 120 | func 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 | |||
| 132 | func 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 | |||
| 152 | func 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 | |||
| 167 | func 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. | ||
| 185 | func 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 | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | 4 | "encoding/json" |
| 5 | "errors" | ||
| 5 | "os" | 6 | "os" |
| 6 | "regexp" | 7 | "regexp" |
| 7 | "skunkyart/static" | 8 | "skunkyart/static" |
| @@ -27,19 +28,26 @@ type cacheConfig struct { | |||
| 27 | UpdateInterval int64 `json:"update-interval"` | 28 | UpdateInterval int64 `json:"update-interval"` |
| 28 | } | 29 | } |
| 29 | 30 | ||
| 31 | type apiCacheConfig struct { | ||
| 32 | Enabled bool `json:"enabled"` | ||
| 33 | MaxSize int64 `json:"max-size"` | ||
| 34 | TTL string `json:"ttl"` | ||
| 35 | } | ||
| 36 | |||
| 30 | type config struct { | 37 | type config struct { |
| 31 | cfg string | 38 | cfg string |
| 32 | Listen string `json:"listen"` | 39 | Listen string `json:"listen"` |
| 33 | URI string `json:"uri"` | 40 | URI string `json:"uri"` |
| 34 | Cache cacheConfig `json:"cache"` | 41 | Cache cacheConfig `json:"cache"` |
| 35 | Proxy bool `json:"proxy"` | 42 | APICache apiCacheConfig `json:"api-cache"` |
| 36 | Nsfw bool `json:"nsfw"` | 43 | Proxy bool `json:"proxy"` |
| 37 | HideAI bool `json:"hide-ai"` | 44 | Nsfw bool `json:"nsfw"` |
| 38 | Theme string `json:"theme"` | 45 | HideAI bool `json:"hide-ai"` |
| 39 | Language string `json:"language"` | 46 | Theme string `json:"theme"` |
| 40 | UserAgent string `json:"user-agent"` | 47 | Language string `json:"language"` |
| 41 | DownloadProxy string `json:"download-proxy"` | 48 | UserAgent string `json:"user-agent"` |
| 42 | StaticPath string `json:"static-path"` | 49 | DownloadProxy string `json:"download-proxy"` |
| 50 | StaticPath string `json:"static-path"` | ||
| 43 | } | 51 | } |
| 44 | 52 | ||
| 45 | // CFG is the running instance's configuration, holding the defaults below until | 53 | // CFG is the running instance's configuration, holding the defaults below until |
| @@ -55,6 +63,11 @@ var CFG = config{ | |||
| 55 | Path: "cache", | 63 | Path: "cache", |
| 56 | UpdateInterval: 1, | 64 | UpdateInterval: 1, |
| 57 | }, | 65 | }, |
| 66 | APICache: apiCacheConfig{ | ||
| 67 | Enabled: true, | ||
| 68 | MaxSize: 64, | ||
| 69 | TTL: "5i", | ||
| 70 | }, | ||
| 58 | StaticPath: "static", | 71 | StaticPath: "static", |
| 59 | UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", | 72 | UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", |
| 60 | Proxy: true, | 73 | Proxy: true, |
| @@ -63,6 +76,43 @@ var CFG = config{ | |||
| 63 | 76 | ||
| 64 | var lifetimeParsed int64 | 77 | var lifetimeParsed int64 |
| 65 | 78 | ||
| 79 | // apiCacheTTL is api-cache.ttl parsed, set by ExecuteConfig. | ||
| 80 | var 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). | ||
| 85 | func 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 | |||
| 66 | // checkCacheWritable creates the cache directory if it is missing and confirms | 116 | // checkCacheWritable creates the cache directory if it is missing and confirms |
| 67 | // this process can actually write into it, returning the error that a real cache | 117 | // this process can actually write into it, returning the error that a real cache |
| 68 | // write would hit. | 118 | // write would hit. |
| @@ -104,29 +154,11 @@ func ExecuteConfig() { | |||
| 104 | } | 154 | } |
| 105 | 155 | ||
| 106 | if CFG.Cache.Lifetime != "" { | 156 | if CFG.Cache.Lifetime != "" { |
| 107 | var duration int64 | 157 | d, err := parseLifetime(CFG.Cache.Lifetime) |
| 108 | day := 24 * time.Hour.Milliseconds() | 158 | if err != nil { |
| 109 | numstr := regexp.MustCompile("[0-9]+").FindAllString(CFG.Cache.Lifetime, -1) | 159 | exit("config: cache.lifetime: "+err.Error(), 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) | ||
| 127 | } | 160 | } |
| 128 | 161 | lifetimeParsed = d.Milliseconds() | |
| 129 | lifetimeParsed = duration * int64(num) | ||
| 130 | } | 162 | } |
| 131 | // max-size is documented in megabytes. This was 1024^2, which in Go is | 163 | // max-size is documented in megabytes. This was 1024^2, which in Go is |
| 132 | // XOR (1026), not exponentiation — so the cap was ~1000x too small. | 164 | // XOR (1026), not exponentiation — so the cap was ~1000x too small. |
| @@ -152,6 +184,14 @@ func ExecuteConfig() { | |||
| 152 | exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1) | 184 | exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1) |
| 153 | } | 185 | } |
| 154 | 186 | ||
| 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 | |||
| 155 | static.StaticPath = CFG.StaticPath | 195 | static.StaticPath = CFG.StaticPath |
| 156 | devianter.UserAgent = CFG.UserAgent | 196 | devianter.UserAgent = CFG.UserAgent |
| 157 | } | 197 | } |
app/config_test.go added +38
| @@ -0,0 +1,38 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | "time" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func 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 | |||
| 26 | func 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 | |||
| 34 | func 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 { | |||
| 81 | return t | 81 | return t |
| 82 | } | 82 | } |
| 83 | 83 | ||
| 84 | // InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits and | 84 | // daCache is the API response cache shared by every transport, or nil when |
| 85 | // timeouts above. Call once at startup, before any DeviantArt request is made. | 85 | // api-cache.enabled is false. |
| 86 | var 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. | ||
| 90 | func 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. | ||
| 100 | func 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. | ||
| 86 | func InstallDAThrottle() { | 111 | func InstallDAThrottle() { |
| 87 | baseTransport = tunedTransport() | 112 | 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) | ||
| 89 | } | 118 | } |
| 90 | 119 | ||
| 91 | // throttled wraps base with the DeviantArt rate and concurrency limits. | 120 | // throttled wraps base with the DeviantArt rate and concurrency limits. |
| @@ -104,5 +133,5 @@ func ProxiedTransport(proxy *url.URL) http.RoundTripper { | |||
| 104 | base = tunedTransport() | 133 | base = tunedTransport() |
| 105 | } | 134 | } |
| 106 | base.Proxy = http.ProxyURL(proxy) | 135 | base.Proxy = http.ProxyURL(proxy) |
| 107 | return throttled(base) | 136 | return chain(base) |
| 108 | } | 137 | } |
app/httpclient_test.go +14 −4
| @@ -116,14 +116,24 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { retu | |||
| 116 | // InstallDAThrottle must preserve proxy-from-environment so HTTPS_PROXY (VPN | 116 | // InstallDAThrottle must preserve proxy-from-environment so HTTPS_PROXY (VPN |
| 117 | // egress) keeps working, and must not panic on a repeat call. | 117 | // egress) keeps working, and must not panic on a repeat call. |
| 118 | func TestInstallDAThrottlePreservesProxy(t *testing.T) { | 118 | func TestInstallDAThrottlePreservesProxy(t *testing.T) { |
| 119 | orig := http.DefaultTransport | 119 | orig, origCache := http.DefaultTransport, daCache |
| 120 | defer func() { http.DefaultTransport = orig }() | 120 | defer func() { http.DefaultTransport, daCache = orig, origCache }() |
| 121 | 121 | ||
| 122 | InstallDAThrottle() | 122 | InstallDAThrottle() |
| 123 | 123 | ||
| 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) | ||
| 125 | if !ok { | 135 | 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) |
| 127 | } | 137 | } |
| 128 | base, ok := th.base.(*http.Transport) | 138 | base, ok := th.base.(*http.Transport) |
| 129 | if !ok { | 139 | if !ok { |
config.example.json +5
| @@ -9,6 +9,11 @@ | |||
| 9 | "memcache": false, | 9 | "memcache": false, |
| 10 | "update-interval": 5 | 10 | "update-interval": 5 |
| 11 | }, | 11 | }, |
| 12 | "api-cache": { | ||
| 13 | "enabled": true, | ||
| 14 | "max-size": 64, | ||
| 15 | "ttl": "5i" | ||
| 16 | }, | ||
| 12 | "static-path": "static", | 17 | "static-path": "static", |
| 13 | "download-proxy": "", | 18 | "download-proxy": "", |
| 14 | "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", | 19 | "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 | ||
| 6 | page views make one upstream request instead of many. | ||
| 7 | |||
| 8 | **Architecture:** A caching `http.RoundTripper` sits in front of the | ||
| 9 | existing throttle. It stores 200 responses for `/_puppy/` and `/groups/` | ||
| 10 | GETs keyed by URL minus `csrf_token`, bounded by bytes with LRU eviction, | ||
| 11 | with singleflight coalescing of concurrent misses. | ||
| 12 | |||
| 13 | **Tech Stack:** Go 1.25, `golang.org/x/sync/singleflight`, standard | ||
| 14 | library `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 | ||
| 43 | package app | ||
| 44 | |||
| 45 | import ( | ||
| 46 | "testing" | ||
| 47 | "time" | ||
| 48 | ) | ||
| 49 | |||
| 50 | func 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 | |||
| 68 | func 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 | |||
| 76 | func 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 | |||
| 85 | Run: `go test ./app -run 'TestParseLifetime|TestAPICacheDefaults' -count=1` | ||
| 86 | Expected: FAIL, `undefined: parseLifetime`. | ||
| 87 | |||
| 88 | - [ ] **Step 3: Implement** | ||
| 89 | |||
| 90 | In `app/config.go`, add `"errors"` to the imports. Add the type and the | ||
| 91 | field: | ||
| 92 | |||
| 93 | ```go | ||
| 94 | type apiCacheConfig struct { | ||
| 95 | Enabled bool `json:"enabled"` | ||
| 96 | MaxSize int64 `json:"max-size"` | ||
| 97 | TTL string `json:"ttl"` | ||
| 98 | } | ||
| 99 | ``` | ||
| 100 | |||
| 101 | In `config`, after `Cache`: | ||
| 102 | |||
| 103 | ```go | ||
| 104 | APICache apiCacheConfig `json:"api-cache"` | ||
| 105 | ``` | ||
| 106 | |||
| 107 | In 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 | |||
| 117 | After `var lifetimeParsed int64`: | ||
| 118 | |||
| 119 | ```go | ||
| 120 | // apiCacheTTL is api-cache.ttl parsed, set by ExecuteConfig. | ||
| 121 | var 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). | ||
| 126 | func 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 | |||
| 158 | Replace 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 | |||
| 171 | After 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 | |||
| 183 | Remove the now-unused `day` and `duration` locals from `ExecuteConfig`. | ||
| 184 | |||
| 185 | In `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 | |||
| 195 | In `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 | |||
| 210 | Also add `d` — days to the Time units list at the top of `SETUP.md`. | ||
| 211 | |||
| 212 | - [ ] **Step 4: Run tests** | ||
| 213 | |||
| 214 | Run: `go test ./app -count=1 -race` | ||
| 215 | Expected: PASS. | ||
| 216 | |||
| 217 | - [ ] **Step 5: Commit** | ||
| 218 | |||
| 219 | ```bash | ||
| 220 | git add app/config.go app/config_test.go config.example.json SETUP.md | ||
| 221 | git commit -m "Add the api-cache config block and a shared lifetime parser | ||
| 222 | |||
| 223 | Ref #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 | |||
| 243 | Run: `go get golang.org/x/sync@latest && go mod tidy` | ||
| 244 | |||
| 245 | - [ ] **Step 2: Write the failing tests** | ||
| 246 | |||
| 247 | ```go | ||
| 248 | package app | ||
| 249 | |||
| 250 | import ( | ||
| 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. | ||
| 260 | type fakeRT struct { | ||
| 261 | mu sync.Mutex | ||
| 262 | calls int | ||
| 263 | status int | ||
| 264 | body string | ||
| 265 | delay time.Duration | ||
| 266 | } | ||
| 267 | |||
| 268 | func (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 | |||
| 281 | func (f *fakeRT) count() int { | ||
| 282 | f.mu.Lock() | ||
| 283 | defer f.mu.Unlock() | ||
| 284 | return f.calls | ||
| 285 | } | ||
| 286 | |||
| 287 | const puppyURL = "https://www.deviantart.com/_puppy/dabrowse/search/all?q=fox&csrf_token=abc" | ||
| 288 | |||
| 289 | func 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 | |||
| 304 | func 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 | |||
| 319 | func 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 | |||
| 335 | func 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 | |||
| 350 | func 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 | |||
| 367 | func 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 | |||
| 379 | func 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 | |||
| 399 | func 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 | |||
| 414 | func 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 | |||
| 430 | Note: `wg.Go` needs Go 1.25, which `go.mod` already requires. | ||
| 431 | |||
| 432 | - [ ] **Step 3: Run to verify it fails** | ||
| 433 | |||
| 434 | Run: `go test ./app -run 'Cache|Bypasses|Key|Bound|Concurrent|Stats' -count=1` | ||
| 435 | Expected: FAIL, `undefined: newAPICache`. | ||
| 436 | |||
| 437 | - [ ] **Step 4: Implement `app/apicache.go`** | ||
| 438 | |||
| 439 | ```go | ||
| 440 | package app | ||
| 441 | |||
| 442 | import ( | ||
| 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. | ||
| 460 | type 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. | ||
| 477 | type 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 | |||
| 486 | func 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. | ||
| 499 | func 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. | ||
| 510 | func 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. | ||
| 520 | func (c *apiCache) transport(base http.RoundTripper) http.RoundTripper { | ||
| 521 | return &cachedTransport{cache: c, base: base} | ||
| 522 | } | ||
| 523 | |||
| 524 | type 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. | ||
| 532 | func (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. | ||
| 569 | func (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. | ||
| 585 | func (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. | ||
| 606 | func (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. | ||
| 632 | func (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. | ||
| 639 | func (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 | |||
| 648 | Run: `go test ./app -count=1 -race` | ||
| 649 | Expected: PASS. | ||
| 650 | |||
| 651 | - [ ] **Step 6: Lint** | ||
| 652 | |||
| 653 | Run: `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./...` | ||
| 654 | Expected: `0 issues.` | ||
| 655 | |||
| 656 | - [ ] **Step 7: Commit** | ||
| 657 | |||
| 658 | ```bash | ||
| 659 | git add app/apicache.go app/apicache_test.go go.mod go.sum | ||
| 660 | git commit -m "Add an in-memory cache for DeviantArt API responses | ||
| 661 | |||
| 662 | Ref #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. | ||
| 686 | func 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 | |||
| 709 | Run: `go test ./app -run TestHitDoesNotConsumeAThrottleSlot -count=1` | ||
| 710 | Expected: PASS already, since the test builds the chain by hand. That is | ||
| 711 | fine: it documents the required order, and Step 3 makes the production | ||
| 712 | chain match it. | ||
| 713 | |||
| 714 | - [ ] **Step 3: Implement** | ||
| 715 | |||
| 716 | In `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. | ||
| 721 | var 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. | ||
| 725 | func 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. | ||
| 735 | func 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 | |||
| 744 | Change `InstallDAThrottle` to: | ||
| 745 | |||
| 746 | ```go | ||
| 747 | func 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 | |||
| 757 | Update its doc comment to mention the cache. In `ProxiedTransport`, | ||
| 758 | replace `return throttled(base)` with `return chain(base)`. | ||
| 759 | |||
| 760 | - [ ] **Step 4: Run tests and lint** | ||
| 761 | |||
| 762 | Run: `go test ./... -count=1 -race && go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./...` | ||
| 763 | Expected: PASS, `0 issues.` | ||
| 764 | |||
| 765 | - [ ] **Step 5: Commit** | ||
| 766 | |||
| 767 | ```bash | ||
| 768 | git add app/httpclient.go app/apicache_test.go | ||
| 769 | git commit -m "Serve DeviantArt API responses from the cache | ||
| 770 | |||
| 771 | The cache sits in front of the throttle, so a hit costs neither an | ||
| 772 | upstream request nor a throttle slot. On by default; api-cache.enabled | ||
| 773 | turns it off. | ||
| 774 | |||
| 775 | Closes #8" | ||
| 776 | ``` | ||
docs/superpowers/specs/2026-09-10-api-cache-design.md added +117
| @@ -0,0 +1,117 @@ | |||
| 1 | # API response cache | ||
| 2 | |||
| 3 | Issue #8. Roadmap item 1.1. | ||
| 4 | |||
| 5 | ## Problem | ||
| 6 | |||
| 7 | Nothing from DeviantArt's JSON API is cached. Every page view re-fetches | ||
| 8 | its JSON, concurrent requests for one page each go upstream, and feed | ||
| 9 | readers and `/api/random` add calls on a schedule. DeviantArt fronts the | ||
| 10 | API with a WAF that bans egress IPs, so the number of upstream requests, | ||
| 11 | not 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 | |||
| 16 | A caching `http.RoundTripper` in `app/apicache.go`. `InstallDAThrottle` | ||
| 17 | builds the chain | ||
| 18 | |||
| 19 | devianter -> cache -> throttle -> base transport | ||
| 20 | |||
| 21 | so a hit is answered before the throttle's rate limiter and semaphore are | ||
| 22 | consulted, and a miss goes through them as today. `ProxiedTransport` | ||
| 23 | builds the same chain over its proxied base, sharing the one cache | ||
| 24 | instance. | ||
| 25 | |||
| 26 | ## Scope | ||
| 27 | |||
| 28 | Cached: GET requests to host `www.deviantart.com` whose path starts with | ||
| 29 | `/_puppy/` (with something after it) or `/groups/`, when the response | ||
| 30 | status is 200. | ||
| 31 | |||
| 32 | Passed through, never stored: every other method, host or path. That | ||
| 33 | includes the session bootstrap (`/_puppy` bare), the homepage CSRF scrape, | ||
| 34 | avatars and emotes on `a.deviantart.net` and `e.deviantart.net` (issue #9), | ||
| 35 | and wixmp media. Non-200 responses and transport errors are returned | ||
| 36 | unchanged and not stored, so a WAF block is not remembered. | ||
| 37 | |||
| 38 | ## Key | ||
| 39 | |||
| 40 | The request URL with the `csrf_token` query parameter removed, otherwise | ||
| 41 | verbatim. The token changes every twelve hours and would otherwise empty | ||
| 42 | the cache on each refresh. The guest cookie is the same for every request | ||
| 43 | and is not part of the key. | ||
| 44 | |||
| 45 | ## Entry | ||
| 46 | |||
| 47 | Status, `Content-Type`, body bytes, expiry time. A hit returns a new | ||
| 48 | `*http.Response` with those headers, `ContentLength` set, and the body as | ||
| 49 | a `bytes.Reader`. devianter reads the body and closes it as with a live | ||
| 50 | response. | ||
| 51 | |||
| 52 | ## Coalescing | ||
| 53 | |||
| 54 | `golang.org/x/sync/singleflight` keyed the same as the cache. Concurrent | ||
| 55 | misses for one key make one upstream request; every waiter receives the | ||
| 56 | same stored entry. A miss whose upstream result is not cacheable is still | ||
| 57 | shared with the waiters of that flight, then not stored. | ||
| 58 | |||
| 59 | ## Bounds | ||
| 60 | |||
| 61 | Bounded 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 | ||
| 63 | it and reports a miss. An insert that would exceed the bound evicts from | ||
| 64 | the least recently used end until it fits. A body larger than the bound is | ||
| 65 | served but not stored. No background goroutine. | ||
| 66 | |||
| 67 | ## Configuration | ||
| 68 | |||
| 69 | New 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` | ||
| 78 | uses the same unit syntax as `cache.lifetime` (`i` minutes, `h` hours, | ||
| 79 | `d` days, `w` weeks, `m` months, `y` years), default five minutes. The | ||
| 80 | lifetime parser in `config.go` becomes a function both blocks call; an | ||
| 81 | unparseable value exits at startup with the same message as today. | ||
| 82 | |||
| 83 | One TTL for every endpoint. Per-endpoint values are not in scope. | ||
| 84 | |||
| 85 | ## Observability | ||
| 86 | |||
| 87 | Every hour, one line on stdout: hits, misses, entries, bytes held. Nothing | ||
| 88 | else. No endpoint. | ||
| 89 | |||
| 90 | ## Errors | ||
| 91 | |||
| 92 | Upstream errors and non-200 statuses pass through unchanged. A body read | ||
| 93 | error 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 | ||
| 98 | and 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 @@ | |||
| 1 | module skunkyart | 1 | module skunkyart |
| 2 | 2 | ||
| 3 | go 1.25.0 | 3 | go 1.26.0 |
| 4 | 4 | ||
| 5 | require ( | 5 | require ( |
| 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 | |||
| 10 | require 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 | |||
| 2 | github.com/krazywarez/devianter v0.3.4/go.mod h1:d+0cnLQqQNoiuVCuCMxvox3tLNj3n2vEWv7bqVn+2s8= | 2 | github.com/krazywarez/devianter v0.3.4/go.mod h1:d+0cnLQqQNoiuVCuCMxvox3tLNj3n2vEWv7bqVn+2s8= |
| 3 | golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= | 3 | golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= |
| 4 | golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= | 4 | golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= |
| 5 | golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= | ||
| 6 | golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= | ||