Commit 53545b11e2
Verified · cmc
Layout: unified · split
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` | |