Proxy random media with the token in the query, and bootstrap before listening !28

merged merged by cmc on 2026-09-11 18:57 UTC · krz/skunky-art:fix/random-media into main

11 files changed, +121 −282

Layout: unified · split

SETUP.md −5
@@ -35,11 +35,6 @@ below apply. A file named with `-c` must exist.
3535 recently used entries are dropped past this.
3636 * `ttl` — How long a response is reused, in the time units below. Default
3737 `5i`.
38 * `stale` — How long past `ttl` a response is kept to be served when
39 DeviantArt fails or blocks the instance, so a short ban does not take
40 the daily deviations, popular searches and feeds down. Default `1h`;
41 `0i` keeps nothing past `ttl`. After a block the instance also leaves
42 DeviantArt alone for a minute instead of retrying every request.
4338* `rate-limit` — Per-client budget for page, feed and API requests, so one
4439 crawler cannot spend the whole upstream budget. Media, avatars and static
4540 files are not counted. Over budget answers 429 with `Retry-After`.
app/api.go +15 −6
@@ -3,6 +3,7 @@ package app
33import (
44 "encoding/json"
55 "math/rand"
6 "net/url"
67 "strings"
78
89 "github.com/krazywarez/devianter"
@@ -52,15 +53,23 @@ func (a API) sendMedia(d *devianter.Deviation) {
5253 return
5354 }
5455
55 if CFG.Proxy {
56 mediaURL = mediaURL[21:]
57 dot := strings.Index(mediaURL, ".")
58 a.main.Writer.Header().Del("Content-Type")
59 a.main.DownloadAndSendMedia(mediaURL[:dot], mediaURL[dot+11:])
60 } else {
56 if !CFG.Proxy {
6157 a.main.Writer.Header().Add("Location", mediaURL)
6258 a.main.Writer.WriteHeader(302)
59 return
60 }
61
62 // Parsed, not sliced: the signing token has to reach wixmp as a query
63 // parameter. Passing the raw tail as the path put "?token=..." inside
64 // the path, which wixmp answers with 401.
65 u, err := url.Parse(mediaURL)
66 if err != nil {
67 a.Error("bad media url", 502)
68 return
6369 }
70 subdomain := strings.TrimSuffix(strings.TrimPrefix(u.Host, "images-wixmp-"), ".wixmp.com")
71 a.main.Writer.Header().Del("Content-Type")
72 a.main.downloadAndSendMedia(subdomain, strings.TrimPrefix(u.Path, "/"), u.Query().Get("token"))
6473}
6574
6675// fetchDailyDeviations is devianter.GetDailyDeviations behind a variable so
app/api_test.go +37
@@ -1,7 +1,10 @@
11package app
22
33import (
4 "net/http"
45 "net/http/httptest"
6 "net/url"
7 "strings"
58 "testing"
69
710 "github.com/krazywarez/devianter"
@@ -108,3 +111,37 @@ func TestRandomHonoursNSFW(t *testing.T) {
108111 t.Errorf("status %d, Location %q; want 404 and no media", w.Code, w.Header().Get("Location"))
109112 }
110113}
114
115// TestSendMediaProxiesWithTheTokenInTheQuery is the regression test for
116// /api/random answering 401 with proxying on: the signing token was passed
117// inside the path, so wixmp never saw it as a parameter.
118func TestSendMediaProxiesWithTheTokenInTheQuery(t *testing.T) {
119 proxy, cache := CFG.Proxy, CFG.Cache.Enabled
120 CFG.Proxy, CFG.Cache.Enabled = true, false
121 defer func() { CFG.Proxy, CFG.Cache.Enabled = proxy, cache }()
122
123 var fetched string
124 orig := fetchMedia
125 fetchMedia = func(u string) Downloaded {
126 fetched = u
127 return Downloaded{Status: 200, Body: []byte("png"), Headers: http.Header{"Content-Type": {"image/png"}}}
128 }
129 defer func() { fetchMedia = orig }()
130
131 d := fullviewDeviation()
132 d.Media.Token = []string{"tok.en.sig"}
133
134 w := httptest.NewRecorder()
135 API{main: &skunkyart{Writer: w, Args: url.Values{}}}.sendMedia(d)
136
137 u, err := url.Parse(fetched)
138 if err != nil || u.Host != "images-wixmp-abc.wixmp.com" {
139 t.Fatalf("fetched %q, want a wixmp URL on the deviation's subdomain", fetched)
140 }
141 if strings.Contains(u.Path, "token") || u.Query().Get("token") == "" {
142 t.Errorf("token not passed as a query parameter: path %q query %q", u.Path, u.RawQuery)
143 }
144 if w.Code != 200 || w.Body.String() != "png" {
145 t.Errorf("response %d %q, want the proxied image", w.Code, w.Body.String())
146 }
147}
app/apicache.go +28 −98
@@ -21,31 +21,18 @@ import (
2121type apiCache struct {
2222 maxBytes int64
2323 ttl time.Duration
24 // stale is how long past ttl an entry is kept to be served when upstream
25 // is blocked or unreachable. Zero keeps nothing past ttl.
26 stale time.Duration
27 now func() time.Time
24 now func() time.Time
2825
29 mu sync.Mutex
30 entries map[string]*cacheEntry
31 lru *list.List // front is most recently used
32 held int64
33 hits int64
34 misses int64
35 staleHits int64
36
37 // blockedUntil is set when DeviantArt answers with a block. Until then a
38 // miss with nothing stale to serve gets blockResp back without an
39 // upstream call, so a banned instance stops hammering the WAF.
40 blockedUntil time.Time
41 blockResp *cacheEntry
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
4232
4333 flight singleflight.Group
4434}
4535
46// blockBackoff is how long upstream is left alone after a block response.
47const blockBackoff = time.Minute
48
4936// cacheEntry is one buffered response. header is a clone of the upstream
5037// header; body is the whole body, read once.
5138type cacheEntry struct {
@@ -57,11 +44,10 @@ type cacheEntry struct {
5744 elem *list.Element
5845}
5946
60func newAPICache(maxBytes int64, ttl, stale time.Duration) *apiCache {
47func newAPICache(maxBytes int64, ttl time.Duration) *apiCache {
6148 return &apiCache{
6249 maxBytes: maxBytes,
6350 ttl: ttl,
64 stale: stale,
6551 now: time.Now,
6652 entries: map[string]*cacheEntry{},
6753 lru: list.New(),
@@ -101,29 +87,16 @@ type cachedTransport struct {
10187 base http.RoundTripper
10288}
10389
104// RoundTrip serves a fresh hit from memory. A miss is fetched once per key
105// however many callers are waiting, buffered, stored if it is a 200, and
106// handed to every waiter as its own response.
107//
108// When upstream fails or answers with a block, a stale entry is served
109// instead if one is still held, so a short ban does not take the popular
110// pages down. A block also starts a backoff during which misses with nothing
111// stale get the block response back without an upstream call.
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.
11293func (t *cachedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
11394 if !cacheable(req) {
11495 return t.base.RoundTrip(req)
11596 }
11697 key := cacheKey(req)
117 old, fresh := t.cache.get(key)
118 if fresh {
119 return old.response(req), nil
120 }
121 if blocked := t.cache.blockedResponse(); blocked != nil {
122 if old != nil {
123 t.cache.countStale()
124 return old.response(req), nil
125 }
126 return blocked.response(req), nil
98 if e := t.cache.get(key); e != nil {
99 return e.response(req), nil
127100 }
128101
129102 v, err, _ := t.cache.flight.Do(key, func() (any, error) {
@@ -137,57 +110,21 @@ func (t *cachedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
137110 return nil, err
138111 }
139112 e := &cacheEntry{key: key, status: resp.StatusCode, header: resp.Header.Clone(), body: body}
140 switch e.status {
141 case http.StatusOK:
113 if e.status == http.StatusOK {
142114 t.cache.put(e)
143 case http.StatusForbidden, http.StatusTooManyRequests:
144 t.cache.block(e)
145115 }
146116 return e, nil
147117 })
148118 if err != nil {
149 if old != nil {
150 t.cache.countStale()
151 return old.response(req), nil
152 }
153119 return nil, err
154120 }
155121 e, ok := v.(*cacheEntry)
156122 if !ok {
157123 return nil, io.ErrUnexpectedEOF
158124 }
159 if e.status != http.StatusOK && old != nil {
160 t.cache.countStale()
161 return old.response(req), nil
162 }
163125 return e.response(req), nil
164126}
165127
166// block records a block response and starts the backoff.
167func (c *apiCache) block(e *cacheEntry) {
168 c.mu.Lock()
169 defer c.mu.Unlock()
170 c.blockedUntil = c.now().Add(blockBackoff)
171 c.blockResp = e
172}
173
174// blockedResponse returns the last block response while the backoff runs,
175// or nil once it is over.
176func (c *apiCache) blockedResponse() *cacheEntry {
177 c.mu.Lock()
178 defer c.mu.Unlock()
179 if c.blockResp != nil && c.now().Before(c.blockedUntil) {
180 return c.blockResp
181 }
182 return nil
183}
184
185func (c *apiCache) countStale() {
186 c.mu.Lock()
187 c.staleHits++
188 c.mu.Unlock()
189}
190
191128// response builds a fresh http.Response over the buffered body, so each
192129// caller can read and close its own.
193130func (e *cacheEntry) response(req *http.Request) *http.Response {
@@ -204,31 +141,25 @@ func (e *cacheEntry) response(req *http.Request) *http.Response {
204141 }
205142}
206143
207// get returns the entry for key and whether it is still fresh. A fresh hit is
208// marked most recently used. An entry past ttl but within the stale window is
209// returned as not fresh, for the caller to fall back on; one past the stale
210// window is dropped.
211func (c *apiCache) get(key string) (*cacheEntry, bool) {
144// get returns the live entry for key, marking it most recently used, or nil.
145// An expired entry is dropped on the way out.
146func (c *apiCache) get(key string) *cacheEntry {
212147 c.mu.Lock()
213148 defer c.mu.Unlock()
214149
215150 e := c.entries[key]
216151 if e == nil {
217152 c.misses++
218 return nil, false
153 return nil
219154 }
220 now := c.now()
221 if now.Before(e.expires) {
222 c.lru.MoveToFront(e.elem)
223 c.hits++
224 return e, true
225 }
226 c.misses++
227 if now.Before(e.expires.Add(c.stale)) {
228 return e, false
155 if !c.now().Before(e.expires) {
156 c.remove(e)
157 c.misses++
158 return nil
229159 }
230 c.remove(e)
231 return nil, false
160 c.lru.MoveToFront(e.elem)
161 c.hits++
162 return e
232163}
233164
234165// put stores e, evicting from the least recently used end until it fits. A
@@ -265,10 +196,9 @@ func (c *apiCache) remove(e *cacheEntry) {
265196 c.held -= int64(len(e.body))
266197}
267198
268// stats reports the counters for the hourly log line. stale counts the
269// misses that were answered from an expired entry because upstream failed.
270func (c *apiCache) stats() (hits, misses, stale int64, entries int, held int64) {
199// stats reports the counters for the hourly log line.
200func (c *apiCache) stats() (hits, misses int64, entries int, held int64) {
271201 c.mu.Lock()
272202 defer c.mu.Unlock()
273 return c.hits, c.misses, c.staleHits, len(c.entries), c.held
203 return c.hits, c.misses, len(c.entries), c.held
274204}
app/apicache_stale_test.go deleted −132
@@ -1,132 +0,0 @@
1package app
2
3import (
4 "errors"
5 "net/http"
6 "testing"
7 "time"
8)
9
10// failingRT is an upstream that can be switched between a scripted status and
11// a transport error mid-test.
12type failingRT struct {
13 fakeRT
14 err error
15}
16
17func (f *failingRT) RoundTrip(r *http.Request) (*http.Response, error) {
18 f.mu.Lock()
19 err := f.err
20 f.mu.Unlock()
21 if err != nil {
22 f.mu.Lock()
23 f.calls++
24 f.mu.Unlock()
25 return nil, err
26 }
27 return f.fakeRT.RoundTrip(r)
28}
29
30func (f *failingRT) set(status int, body string, err error) {
31 f.mu.Lock()
32 defer f.mu.Unlock()
33 f.status, f.body, f.err = status, body, err
34}
35
36// staleCache returns a cache with a one minute TTL and one hour stale window
37// over a controllable clock, warmed with one good response for puppyURL.
38func staleCache(t *testing.T) (*apiCache, *failingRT, http.RoundTripper, *time.Time) {
39 t.Helper()
40 up := &failingRT{}
41 up.set(200, `{"good":1}`, nil)
42 c := newAPICache(1<<20, time.Minute, time.Hour)
43 now := time.Now()
44 c.now = func() time.Time { return now }
45 rt := c.transport(up)
46 get(t, rt, puppyURL)
47 return c, up, rt, &now
48}
49
50func TestStaleEntryServedWhenUpstreamBlocks(t *testing.T) {
51 c, up, rt, now := staleCache(t)
52 *now = now.Add(2 * time.Minute) // past ttl, inside the stale window
53 up.set(403, "<html>blocked</html>", nil)
54
55 status, body := get(t, rt, puppyURL)
56
57 if status != 200 || body != `{"good":1}` {
58 t.Errorf("got %d %q, want the stale 200 body", status, body)
59 }
60 if up.count() != 2 {
61 t.Errorf("upstream called %d times, want 2: one warm-up and one attempt that hit the block", up.count())
62 }
63 if _, _, stale, _, _ := c.stats(); stale != 1 {
64 t.Errorf("stale counter is %d, want 1", stale)
65 }
66}
67
68func TestStaleEntryServedOnTransportError(t *testing.T) {
69 _, up, rt, now := staleCache(t)
70 *now = now.Add(2 * time.Minute)
71 up.set(0, "", errors.New("dial tcp: connection refused"))
72
73 status, body := get(t, rt, puppyURL)
74
75 if status != 200 || body != `{"good":1}` {
76 t.Errorf("got %d %q, want the stale 200 body", status, body)
77 }
78}
79
80func TestBlockBackoffSkipsUpstream(t *testing.T) {
81 _, up, rt, now := staleCache(t)
82 *now = now.Add(2 * time.Minute)
83 up.set(403, "<html>blocked</html>", nil)
84 get(t, rt, puppyURL) // triggers the block
85 calls := up.count()
86
87 other := "https://www.deviantart.com/_puppy/dabrowse/search/all?q=other"
88 status, body := get(t, rt, other)
89 if status != 403 || body != "<html>blocked</html>" {
90 t.Errorf("uncached key during backoff got %d %q, want the block response", status, body)
91 }
92 if up.count() != calls {
93 t.Errorf("upstream called during backoff (%d -> %d), want none", calls, up.count())
94 }
95
96 *now = now.Add(blockBackoff + time.Second)
97 up.set(200, `{"back":1}`, nil)
98 if status, _ := get(t, rt, other); status != 200 || up.count() != calls+1 {
99 t.Errorf("after backoff: status %d, calls %d, want 200 and one more upstream call", status, up.count())
100 }
101}
102
103func TestStaleEntryDroppedAfterTheWindow(t *testing.T) {
104 c, up, rt, now := staleCache(t)
105 *now = now.Add(time.Minute + time.Hour + time.Second) // past ttl and stale
106 up.set(403, "<html>blocked</html>", nil)
107
108 status, _ := get(t, rt, puppyURL)
109
110 if status != 403 {
111 t.Errorf("got %d, want the 403 passed through once nothing stale is held", status)
112 }
113 if _, _, _, entries, _ := c.stats(); entries != 0 {
114 t.Errorf("%d entries held, want 0", entries)
115 }
116}
117
118func TestZeroStaleKeepsNothingPastTTL(t *testing.T) {
119 up := &failingRT{}
120 up.set(200, "x", nil)
121 c := newAPICache(1<<20, time.Minute, 0)
122 now := time.Now()
123 c.now = func() time.Time { return now }
124 rt := c.transport(up)
125 get(t, rt, puppyURL)
126 now = now.Add(2 * time.Minute)
127 up.set(403, "blocked", nil)
128
129 if status, _ := get(t, rt, puppyURL); status != 403 {
130 t.Errorf("got %d with stale 0, want 403: nothing may be served past ttl", status)
131 }
132}
app/apicache_test.go +16 −18
@@ -56,7 +56,7 @@ func get(t *testing.T, rt http.RoundTripper, url string) (int, string) {
5656
5757func TestSecondRequestIsServedFromCache(t *testing.T) {
5858 up := &fakeRT{status: 200, body: `{"a":1}`}
59 rt := newAPICache(1<<20, time.Minute, 0).transport(up)
59 rt := newAPICache(1<<20, time.Minute).transport(up)
6060
6161 get(t, rt, puppyURL)
6262 status, body := get(t, rt, puppyURL)
@@ -71,7 +71,7 @@ func TestSecondRequestIsServedFromCache(t *testing.T) {
7171
7272func TestExpiredEntryIsRefetched(t *testing.T) {
7373 up := &fakeRT{status: 200, body: `{}`}
74 c := newAPICache(1<<20, time.Minute, 0)
74 c := newAPICache(1<<20, time.Minute)
7575 now := time.Now()
7676 c.now = func() time.Time { return now }
7777 rt := c.transport(up)
@@ -85,26 +85,24 @@ func TestExpiredEntryIsRefetched(t *testing.T) {
8585 }
8686}
8787
88// A 500 here rather than a 403: a 403 is a block and starts the backoff,
89// which is covered in apicache_stale_test.go.
9088func TestNon200IsNotStored(t *testing.T) {
91 up := &fakeRT{status: 500, body: "upstream broke"}
92 rt := newAPICache(1<<20, time.Minute, 0).transport(up)
89 up := &fakeRT{status: 403, body: "blocked"}
90 rt := newAPICache(1<<20, time.Minute).transport(up)
9391
9492 status, body := get(t, rt, puppyURL)
9593 get(t, rt, puppyURL)
9694
97 if status != 500 || body != "upstream broke" {
98 t.Errorf("first response is %d %q, want the upstream 500 passed through", status, body)
95 if status != 403 || body != "blocked" {
96 t.Errorf("first response is %d %q, want the upstream 403 passed through", status, body)
9997 }
10098 if up.count() != 2 {
101 t.Errorf("upstream called %d times, want 2: a 500 must not be cached", up.count())
99 t.Errorf("upstream called %d times, want 2: a 403 must not be cached", up.count())
102100 }
103101}
104102
105103func TestBypassesSessionAndOtherHosts(t *testing.T) {
106104 up := &fakeRT{status: 200, body: "x"}
107 rt := newAPICache(1<<20, time.Minute, 0).transport(up)
105 rt := newAPICache(1<<20, time.Minute).transport(up)
108106
109107 for _, url := range []string{
110108 "https://www.deviantart.com/_puppy",
@@ -121,7 +119,7 @@ func TestBypassesSessionAndOtherHosts(t *testing.T) {
121119
122120func TestKeyIgnoresCSRFToken(t *testing.T) {
123121 up := &fakeRT{status: 200, body: "x"}
124 rt := newAPICache(1<<20, time.Minute, 0).transport(up)
122 rt := newAPICache(1<<20, time.Minute).transport(up)
125123
126124 get(t, rt, puppyURL)
127125 get(t, rt, strings.Replace(puppyURL, "csrf_token=abc", "csrf_token=def", 1))
@@ -133,7 +131,7 @@ func TestKeyIgnoresCSRFToken(t *testing.T) {
133131
134132func TestByteBoundEvictsLeastRecentlyUsed(t *testing.T) {
135133 up := &fakeRT{status: 200, body: strings.Repeat("x", 100)}
136 rt := newAPICache(250, time.Minute, 0).transport(up)
134 rt := newAPICache(250, time.Minute).transport(up)
137135 a := "https://www.deviantart.com/_puppy/a?p=1"
138136 b := "https://www.deviantart.com/_puppy/b?p=1"
139137 c := "https://www.deviantart.com/_puppy/c?p=1"
@@ -153,7 +151,7 @@ func TestByteBoundEvictsLeastRecentlyUsed(t *testing.T) {
153151
154152func TestConcurrentMissesMakeOneUpstreamCall(t *testing.T) {
155153 up := &fakeRT{status: 200, body: "x", delay: 50 * time.Millisecond}
156 rt := newAPICache(1<<20, time.Minute, 0).transport(up)
154 rt := newAPICache(1<<20, time.Minute).transport(up)
157155
158156 var wg sync.WaitGroup
159157 for range 20 {
@@ -168,16 +166,16 @@ func TestConcurrentMissesMakeOneUpstreamCall(t *testing.T) {
168166
169167func TestStatsCountHitsAndMisses(t *testing.T) {
170168 up := &fakeRT{status: 200, body: "abc"}
171 c := newAPICache(1<<20, time.Minute, 0)
169 c := newAPICache(1<<20, time.Minute)
172170 rt := c.transport(up)
173171
174172 get(t, rt, puppyURL)
175173 get(t, rt, puppyURL)
176174 get(t, rt, puppyURL)
177175
178 hits, misses, stale, entries, held := c.stats()
179 if hits != 2 || misses != 1 || stale != 0 || entries != 1 || held != 3 {
180 t.Errorf("stats = %d hits, %d misses, %d stale, %d entries, %d bytes; want 2, 1, 0, 1, 3", hits, misses, stale, entries, held)
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)
181179 }
182180}
183181
@@ -187,7 +185,7 @@ func TestStatsCountHitsAndMisses(t *testing.T) {
187185func TestHitDoesNotConsumeAThrottleSlot(t *testing.T) {
188186 up := &fakeRT{status: 200, body: "x"}
189187 th := &daThrottle{base: up, sem: make(chan struct{}, 1)}
190 rt := newAPICache(1<<20, time.Minute, 0).transport(th)
188 rt := newAPICache(1<<20, time.Minute).transport(th)
191189
192190 get(t, rt, puppyURL) // populate through the throttle
193191
app/cache.go +10 −3
@@ -194,7 +194,14 @@ func buildMediaURL(subdomain, path, token string) (string, bool) {
194194// client, serving it from the on-disk or in-memory cache when enabled. It
195195// responds 403 when proxying is turned off for this instance.
196196func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
197 mediaURL, ok := buildMediaURL(subdomain, path, s.Args.Get("token"))
197 s.downloadAndSendMedia(subdomain, path, s.Args.Get("token"))
198}
199
200// fetchMedia is Download behind a variable so tests can script the CDN.
201var fetchMedia = Download
202
203func (s skunkyart) downloadAndSendMedia(subdomain, path, token string) {
204 mediaURL, ok := buildMediaURL(subdomain, path, token)
198205 if !ok {
199206 s.ReturnHTTPError(400)
200207 return
@@ -225,7 +232,7 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
225232 memPut(key, response)
226233 }
227234 case CFG.Proxy:
228 dwnld := Download(mediaURL)
235 dwnld := fetchMedia(mediaURL)
229236 if dwnld.Status != 200 {
230237 s.ReturnHTTPError(dwnld.Status)
231238 return
@@ -258,7 +265,7 @@ func (s skunkyart) loadOrFetchMedia(filePath, mediaURL string) ([]byte, bool) {
258265 }
259266 }
260267
261 dwnld := Download(mediaURL)
268 dwnld := fetchMedia(mediaURL)
262269 if dwnld.Status != 200 || !strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") {
263270 s.ReturnHTTPError(dwnld.Status)
264271 return nil, false
app/config.go +2 −12
@@ -32,7 +32,6 @@ type apiCacheConfig struct {
3232 Enabled bool `json:"enabled"`
3333 MaxSize int64 `json:"max-size"`
3434 TTL string `json:"ttl"`
35 Stale string `json:"stale"`
3635}
3736
3837type rateLimitConfig struct {
@@ -76,7 +75,6 @@ var CFG = config{
7675 Enabled: true,
7776 MaxSize: 64,
7877 TTL: "5i",
79 Stale: "1h",
8078 },
8179 RateLimit: rateLimitConfig{
8280 PerMinute: 60,
@@ -90,9 +88,8 @@ var CFG = config{
9088
9189var lifetimeParsed int64
9290
93// apiCacheTTL and apiCacheStale are api-cache.ttl and api-cache.stale parsed,
94// set by ExecuteConfig.
95var apiCacheTTL, apiCacheStale time.Duration
91// apiCacheTTL is api-cache.ttl parsed, set by ExecuteConfig.
92var apiCacheTTL time.Duration
9693
9794// parseLifetime reads a duration in the config's unit syntax: a number
9895// followed by i (minutes), h (hours), d (days), w (weeks), m (30-day
@@ -219,13 +216,6 @@ func ExecuteConfig() {
219216 exit("config: api-cache.ttl: "+err.Error(), 1)
220217 }
221218 apiCacheTTL = d
222 if CFG.APICache.Stale != "" {
223 d, err := parseLifetime(CFG.APICache.Stale)
224 if err != nil {
225 exit("config: api-cache.stale: "+err.Error(), 1)
226 }
227 apiCacheStale = d
228 }
229219 }
230220
231221 // per-minute 0 turns the limit off; a burst below one token would
app/config_test.go +2 −2
@@ -32,8 +32,8 @@ func TestParseLifetimeRejectsBadInput(t *testing.T) {
3232}
3333
3434func TestAPICacheDefaults(t *testing.T) {
35 if !CFG.APICache.Enabled || CFG.APICache.MaxSize != 64 || CFG.APICache.TTL != "5i" || CFG.APICache.Stale != "1h" {
36 t.Errorf("defaults are %+v, want enabled, 64 MB, 5i, stale 1h", CFG.APICache)
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)
3737 }
3838}
3939
app/httpclient.go +3 −3
@@ -100,8 +100,8 @@ func chain(base http.RoundTripper) http.RoundTripper {
100100func logCacheStatsForever(c *apiCache) {
101101 for {
102102 time.Sleep(time.Hour)
103 hits, misses, stale, entries, held := c.stats()
104 println("api cache:", hits, "hits,", misses, "misses,", stale, "served stale,", entries, "entries,", held>>20, "MB held")
103 hits, misses, entries, held := c.stats()
104 println("api cache:", hits, "hits,", misses, "misses,", entries, "entries,", held>>20, "MB held")
105105 }
106106}
107107
@@ -111,7 +111,7 @@ func logCacheStatsForever(c *apiCache) {
111111func InstallDAThrottle() {
112112 baseTransport = tunedTransport()
113113 if CFG.APICache.Enabled {
114 daCache = newAPICache(CFG.APICache.MaxSize<<20, apiCacheTTL, apiCacheStale)
114 daCache = newAPICache(CFG.APICache.MaxSize<<20, apiCacheTTL)
115115 go logCacheStatsForever(daCache)
116116 }
117117 http.DefaultTransport = chain(baseTransport)
main.go +8 −3
@@ -39,13 +39,18 @@ func main() {
3939 // and let the request escape the throttle and the configured User-Agent.
4040 go app.RefreshInstances()
4141
42 // The first session bootstrap runs before the listener opens: requests
43 // that arrive before it finishes go to DeviantArt without a token and
44 // fail, which showed up as 502s for the first seconds after a restart.
45 if err := devianter.UpdateCSRF(); err != nil {
46 println(err.Error())
47 }
4248 go func() {
4349 for {
44 err := devianter.UpdateCSRF()
45 if err != nil {
50 time.Sleep(12 * time.Hour)
51 if err := devianter.UpdateCSRF(); err != nil {
4652 println(err.Error())
4753 }
48 time.Sleep(12 * time.Hour)
4954 }
5055 }()
5156