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.
5050 `0` turns the limit off.
5151 * `burst` — How many requests a client can make at once before the rate
5252 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.
5359* `static-path` — Directory of templates, styles and catalogues, read into
5460 memory at startup. Default `static`. Ignored by a binary built with the
5561 `embed` tag.
app/config.go +16
@@ -40,6 +40,12 @@ type rateLimitConfig struct {
4040 Burst int `json:"burst"`
4141}
4242
43// upstreamConfig paces the instance's own requests to DeviantArt.
44type upstreamConfig struct {
45 MinIntervalMS int `json:"min-interval-ms"`
46 MaxConcurrent int `json:"max-concurrent"`
47}
48
4349type config struct {
4450 cfg string
4551 Listen string `json:"listen"`
@@ -47,6 +53,7 @@ type config struct {
4753 Cache cacheConfig `json:"cache"`
4854 APICache apiCacheConfig `json:"api-cache"`
4955 RateLimit rateLimitConfig `json:"rate-limit"`
56 Upstream upstreamConfig `json:"upstream"`
5057 Proxy bool `json:"proxy"`
5158 Nsfw bool `json:"nsfw"`
5259 HideAI bool `json:"hide-ai"`
@@ -82,6 +89,10 @@ var CFG = config{
8289 PerMinute: 60,
8390 Burst: 20,
8491 },
92 Upstream: upstreamConfig{
93 MinIntervalMS: 400,
94 MaxConcurrent: 2,
95 },
8596 StaticPath: "static",
8697 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
8798 Proxy: true,
@@ -234,6 +245,11 @@ func ExecuteConfig() {
234245 daLimiter = newRateLimiter(CFG.RateLimit.PerMinute, max(CFG.RateLimit.Burst, 1))
235246 }
236247
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
237253 static.StaticPath = CFG.StaticPath
238254 devianter.UserAgent = CFG.UserAgent
239255}
app/config_test.go +27
@@ -1,6 +1,7 @@
11package app
22
33import (
4 "os"
45 "testing"
56 "time"
67)
@@ -80,3 +81,29 @@ func TestExecuteConfigExitsOnAMissingExplicitFile(t *testing.T) {
8081 t.Error("a missing file named with -c started the instance, want an exit")
8182 }
8283}
84
85func 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.
93func 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 (
1818//
1919// http.ProxyFromEnvironment is preserved, so HTTPS_PROXY (VPN egress) still applies.
2020
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.
2224var (
2325 daMinInterval = 400 * time.Millisecond // minimum gap between DA request starts
2426 daMaxConcurrent = 2 // max simultaneous in-flight DA requests
config.example.json +4
@@ -18,6 +18,10 @@
1818 "per-minute": 60,
1919 "burst": 20
2020 },
21 "upstream": {
22 "min-interval-ms": 400,
23 "max-concurrent": 2
24 },
2125 "static-path": "static",
2226 "download-proxy": "",
2327 "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",