Commit 5b2c8a11b2
5b2c8a11b2195cecc635ba205c7f001452d842c7
parent: ec6fbe95a9
Verified · cmc ci/build: success ci/lint: success ci/test: success
cmc <hello@cleberg.net> · 2026-09-11 19:04 UTC
Make the upstream throttle configurable
upstream.min-interval-ms (default 400) and upstream.max-concurrent
(default 2) replace the source constants, so an instance that
DeviantArt bans can slow its own requests down without a rebuild.
Layout: unified · split
SETUP.md
+6
| @@ -50,6 +50,12 @@ below apply. A file named with `-c` must exist. |
| 50 | `0` turns the limit off. |
50 | `0` turns the limit off. |
| 51 | * `burst` — How many requests a client can make at once before the rate |
51 | * `burst` — How many requests a client can make at once before the rate |
| 52 | applies, default 20. |
52 | applies, default 20. |
| |
53 | * `upstream` — How fast the instance itself talks to DeviantArt, whichever |
| |
54 | client asked. DeviantArt bans an address that asks too often, so an |
| |
55 | instance that gets banned should slow this down before anything else. |
| |
56 | * `min-interval-ms` — Minimum gap between two requests to DeviantArt, in |
| |
57 | milliseconds. Default 400. |
| |
58 | * `max-concurrent` — Requests to DeviantArt in flight at once. Default 2. |
| 53 | * `static-path` — Directory of templates, styles and catalogues, read into |
59 | * `static-path` — Directory of templates, styles and catalogues, read into |
| 54 | memory at startup. Default `static`. Ignored by a binary built with the |
60 | memory at startup. Default `static`. Ignored by a binary built with the |
| 55 | `embed` tag. |
61 | `embed` tag. |
app/config.go
+16
| @@ -40,6 +40,12 @@ type rateLimitConfig struct { |
| 40 | Burst int `json:"burst"` |
40 | Burst int `json:"burst"` |
| 41 | } |
41 | } |
| 42 | |
42 | |
| |
43 | // upstreamConfig paces the instance's own requests to DeviantArt. |
| |
44 | type upstreamConfig struct { |
| |
45 | MinIntervalMS int `json:"min-interval-ms"` |
| |
46 | MaxConcurrent int `json:"max-concurrent"` |
| |
47 | } |
| |
48 | |
| 43 | type config struct { |
49 | type config struct { |
| 44 | cfg string |
50 | cfg string |
| 45 | Listen string `json:"listen"` |
51 | Listen string `json:"listen"` |
| @@ -47,6 +53,7 @@ type config struct { |
| 47 | Cache cacheConfig `json:"cache"` |
53 | Cache cacheConfig `json:"cache"` |
| 48 | APICache apiCacheConfig `json:"api-cache"` |
54 | APICache apiCacheConfig `json:"api-cache"` |
| 49 | RateLimit rateLimitConfig `json:"rate-limit"` |
55 | RateLimit rateLimitConfig `json:"rate-limit"` |
| |
56 | Upstream upstreamConfig `json:"upstream"` |
| 50 | Proxy bool `json:"proxy"` |
57 | Proxy bool `json:"proxy"` |
| 51 | Nsfw bool `json:"nsfw"` |
58 | Nsfw bool `json:"nsfw"` |
| 52 | HideAI bool `json:"hide-ai"` |
59 | HideAI bool `json:"hide-ai"` |
| @@ -82,6 +89,10 @@ var CFG = config{ |
| 82 | PerMinute: 60, |
89 | PerMinute: 60, |
| 83 | Burst: 20, |
90 | Burst: 20, |
| 84 | }, |
91 | }, |
| |
92 | Upstream: upstreamConfig{ |
| |
93 | MinIntervalMS: 400, |
| |
94 | MaxConcurrent: 2, |
| |
95 | }, |
| 85 | StaticPath: "static", |
96 | StaticPath: "static", |
| 86 | UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", |
97 | UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", |
| 87 | Proxy: true, |
98 | Proxy: true, |
| @@ -234,6 +245,11 @@ func ExecuteConfig() { |
| 234 | daLimiter = newRateLimiter(CFG.RateLimit.PerMinute, max(CFG.RateLimit.Burst, 1)) |
245 | daLimiter = newRateLimiter(CFG.RateLimit.PerMinute, max(CFG.RateLimit.Burst, 1)) |
| 235 | } |
246 | } |
| 236 | |
247 | |
| |
248 | // Read here so InstallDAThrottle, which runs next, builds the throttle |
| |
249 | // from the file rather than the source defaults. |
| |
250 | daMinInterval = time.Duration(max(CFG.Upstream.MinIntervalMS, 0)) * time.Millisecond |
| |
251 | daMaxConcurrent = max(CFG.Upstream.MaxConcurrent, 1) |
| |
252 | |
| 237 | static.StaticPath = CFG.StaticPath |
253 | static.StaticPath = CFG.StaticPath |
| 238 | devianter.UserAgent = CFG.UserAgent |
254 | devianter.UserAgent = CFG.UserAgent |
| 239 | } |
255 | } |
app/config_test.go
+27
| @@ -1,6 +1,7 @@ |
| 1 | package app |
1 | package app |
| 2 | |
2 | |
| 3 | import ( |
3 | import ( |
| |
4 | "os" |
| 4 | "testing" |
5 | "testing" |
| 5 | "time" |
6 | "time" |
| 6 | ) |
7 | ) |
| @@ -80,3 +81,29 @@ func TestExecuteConfigExitsOnAMissingExplicitFile(t *testing.T) { |
| 80 | t.Error("a missing file named with -c started the instance, want an exit") |
81 | t.Error("a missing file named with -c started the instance, want an exit") |
| 81 | } |
82 | } |
| 82 | } |
83 | } |
| |
84 | |
| |
85 | func TestUpstreamDefaults(t *testing.T) { |
| |
86 | if CFG.Upstream.MinIntervalMS != 400 || CFG.Upstream.MaxConcurrent != 2 { |
| |
87 | t.Errorf("defaults are %+v, want 400 ms and 2 in flight", CFG.Upstream) |
| |
88 | } |
| |
89 | } |
| |
90 | |
| |
91 | // TestUpstreamConfigSetsTheThrottle pins that the file's values reach the |
| |
92 | // throttle: the tunables used to be source constants. |
| |
93 | func TestUpstreamConfigSetsTheThrottle(t *testing.T) { |
| |
94 | dir := t.TempDir() |
| |
95 | path := dir + "/config.json" |
| |
96 | if err := os.WriteFile(path, []byte(`{"upstream": {"min-interval-ms": 1500, "max-concurrent": 1}}`), 0600); err != nil { |
| |
97 | t.Fatal(err) |
| |
98 | } |
| |
99 | withScratchConfig(t, path, true) |
| |
100 | interval, concurrent := daMinInterval, daMaxConcurrent |
| |
101 | t.Cleanup(func() { daMinInterval, daMaxConcurrent = interval, concurrent }) |
| |
102 | captureExit(t) |
| |
103 | |
| |
104 | ExecuteConfig() |
| |
105 | |
| |
106 | if daMinInterval != 1500*time.Millisecond || daMaxConcurrent != 1 { |
| |
107 | t.Errorf("throttle is %v / %d, want 1.5s / 1 from the file", daMinInterval, daMaxConcurrent) |
| |
108 | } |
| |
109 | } |
app/httpclient.go
+3 −1
| @@ -18,7 +18,9 @@ import ( |
| 18 | // |
18 | // |
| 19 | // http.ProxyFromEnvironment is preserved, so HTTPS_PROXY (VPN egress) still applies. |
19 | // http.ProxyFromEnvironment is preserved, so HTTPS_PROXY (VPN egress) still applies. |
| 20 | |
20 | |
| 21 | // Tunables (kept in source; safe defaults). Lower is gentler on the DA IP. |
21 | // Tunables, set from the upstream config block by ExecuteConfig; these are |
| |
22 | // the defaults for a config that omits it. Slower is gentler on the egress |
| |
23 | // address, which DeviantArt bans when it asks too often. |
| 22 | var ( |
24 | var ( |
| 23 | daMinInterval = 400 * time.Millisecond // minimum gap between DA request starts |
25 | daMinInterval = 400 * time.Millisecond // minimum gap between DA request starts |
| 24 | daMaxConcurrent = 2 // max simultaneous in-flight DA requests |
26 | daMaxConcurrent = 2 // max simultaneous in-flight DA requests |
config.example.json
+4
| @@ -18,6 +18,10 @@ |
| 18 | "per-minute": 60, |
18 | "per-minute": 60, |
| 19 | "burst": 20 |
19 | "burst": 20 |
| 20 | }, |
20 | }, |
| |
21 | "upstream": { |
| |
22 | "min-interval-ms": 400, |
| |
23 | "max-concurrent": 2 |
| |
24 | }, |
| 21 | "static-path": "static", |
25 | "static-path": "static", |
| 22 | "download-proxy": "", |
26 | "download-proxy": "", |
| 23 | "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", |
27 | "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", |