Commit 90ffcd3e9d

90ffcd3e9d458bba371e2d640e75145af7e6b02e

parent: 276d65d64c

Verified · cmc ci/build: success ci/lint: success ci/test: success

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

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

Layout: unified · split

app/httpclient.go +33 −4
@@ -81,11 +81,40 @@ func tunedTransport() *http.Transport {
8181 return t
8282}
8383
84// InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits and
85// timeouts above. Call once at startup, before any DeviantArt request is made.
84// daCache is the API response cache shared by every transport, or nil when
85// api-cache.enabled is false.
86var daCache *apiCache
87
88// chain wraps base with the throttle and, when enabled, the cache in front
89// of it, so a hit never spends a throttle slot.
90func chain(base http.RoundTripper) http.RoundTripper {
91 rt := throttled(base)
92 if daCache != nil {
93 return daCache.transport(rt)
94 }
95 return rt
96}
97
98// logCacheStatsForever prints one line an hour so an operator can see the
99// cache working without an endpoint. Run it in its own goroutine.
100func logCacheStatsForever(c *apiCache) {
101 for {
102 time.Sleep(time.Hour)
103 hits, misses, entries, held := c.stats()
104 println("api cache:", hits, "hits,", misses, "misses,", entries, "entries,", held>>20, "MB held")
105 }
106}
107
108// InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits
109// and timeouts above, and with the API response cache when it is enabled. Call
110// once at startup, after ExecuteConfig and before any DeviantArt request.
86111func InstallDAThrottle() {
87112 baseTransport = tunedTransport()
88 http.DefaultTransport = throttled(baseTransport)
113 if CFG.APICache.Enabled {
114 daCache = newAPICache(CFG.APICache.MaxSize<<20, apiCacheTTL)
115 go logCacheStatsForever(daCache)
116 }
117 http.DefaultTransport = chain(baseTransport)
89118}
90119
91120// throttled wraps base with the DeviantArt rate and concurrency limits.
@@ -104,5 +133,5 @@ func ProxiedTransport(proxy *url.URL) http.RoundTripper {
104133 base = tunedTransport()
105134 }
106135 base.Proxy = http.ProxyURL(proxy)
107 return throttled(base)
136 return chain(base)
108137}
app/httpclient_test.go +14 −4
@@ -116,14 +116,24 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { retu
116116// InstallDAThrottle must preserve proxy-from-environment so HTTPS_PROXY (VPN
117117// egress) keeps working, and must not panic on a repeat call.
118118func TestInstallDAThrottlePreservesProxy(t *testing.T) {
119 orig := http.DefaultTransport
120 defer func() { http.DefaultTransport = orig }()
119 orig, origCache := http.DefaultTransport, daCache
120 defer func() { http.DefaultTransport, daCache = orig, origCache }()
121121
122122 InstallDAThrottle()
123123
124 th, ok := http.DefaultTransport.(*daThrottle)
124 // With api-cache on (the default) the cache is outermost and the throttle
125 // sits inside it; with it off the throttle is outermost.
126 rt := http.DefaultTransport
127 if CFG.APICache.Enabled {
128 ct, ok := rt.(*cachedTransport)
129 if !ok {
130 t.Fatalf("DefaultTransport is not the cache, got %T", rt)
131 }
132 rt = ct.base
133 }
134 th, ok := rt.(*daThrottle)
125135 if !ok {
126 t.Fatalf("DefaultTransport was not wrapped, got %T", http.DefaultTransport)
136 t.Fatalf("DefaultTransport was not wrapped by the throttle, got %T", rt)
127137 }
128138 base, ok := th.base.(*http.Transport)
129139 if !ok {