navidrome/server/throttle_backlog_test.go
Deluan Quintão 5f0245ea84
fix(server): prevent artwork throttle token starvation on slow clients (#5472)
* fix(server): prevent artwork throttle token starvation on slow clients

Replace Chi's ThrottleBacklog middleware for artwork endpoints with a
custom RequestThrottle that releases processing tokens before writing
the HTTP response. Previously, a slow or stalled client could hold a
throttle token indefinitely during io.Copy, exhausting all 2-4 slots
and blocking artwork requests for all users (reported after 15+ days
uptime).

The new approach buffers artwork into memory while holding the token,
releases it immediately, then writes the buffered response. A 30-second
per-request write deadline (SetWriteTimeout) prevents stalled writes
from blocking indefinitely. Throttle exhaustion is now logged with
context for operator visibility.

* refactor(server): simplify throttle to middleware with same API as Chi

Restructure RequestThrottle from a DI-injected type into a drop-in
middleware function with the same signature as Chi's ThrottleBacklog.
Handlers are reverted to their original simple form (no throttle
awareness), and the middleware is applied at route definition time
just like before. This eliminates the DI dependency, removes the
artworkThrottle field from both Router structs, and consolidates
SetWriteTimeout into the throttle file. When limit <= 0, the
middleware returns a passthrough so callers don't need a guard.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(server): add opt-out flag for buffered artwork throttle

Add DevArtworkThrottleBuffered config (default true) that controls
whether the new buffered ThrottleBacklog middleware is used. When set
to false, it falls back to Chi's original middleware, giving users a
safety valve in case the buffered implementation causes issues.

Signed-off-by: Deluan <deluan@navidrome.org>

* test(server): clean up throttle tests for clarity and speed

Consolidate duplicate router setup into runTwoRequests() and
slowClientTest() helpers. Replace time.Sleep-based token holding with
channel synchronization, reducing suite time from ~7s to ~1.5s.
Remove redundant test, fix duplicate comment block, and add comment
explaining why slowTestWriter can't embed httptest.ResponseRecorder.

* fix: release artwork throttle tokens on panic

Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic.

* fix: align buffered throttle response behavior

Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling.

* refactor(server): remove setWriteTimeout from throttle middleware

SetWriteDeadline only constrains the server's Write syscall, not how
fast the client reads from the TCP buffer. For artwork-sized responses
(up to ~500KB), the kernel accepts the entire write immediately even
over real network interfaces due to TCP buffer auto-tuning. Verified
by testing with a stalled client over both loopback and en0 — the
deadline never triggers. The actual protection comes from buffering +
early token release, which is already in place.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-06 00:12:50 -04:00

267 lines
7.4 KiB
Go

package server
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ThrottleBacklog", func() {
It("is a passthrough when limit is 0", func() {
m := ThrottleBacklog(0, 10, time.Second)
r := chi.NewRouter()
r.Use(m)
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(Equal("ok"))
})
It("returns 429 when capacity is exceeded", func() {
_, secondStatus := runTwoRequests(ThrottleBacklog(1, 0, time.Second))
Expect(secondStatus).To(Equal(http.StatusTooManyRequests))
})
It("returns 429 when backlog times out", func() {
_, secondStatus := runTwoRequests(ThrottleBacklog(1, 1, 50*time.Millisecond))
Expect(secondStatus).To(Equal(http.StatusTooManyRequests))
})
It("releases capacity when the handler panics", func() {
m := ThrottleBacklog(1, 0, time.Second)
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(m)
r.Get("/panic", func(w http.ResponseWriter, r *http.Request) {
panic("boom")
})
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/panic", nil)
r.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(Equal("ok"))
})
It("preserves response headers and status code", func() {
m := ThrottleBacklog(2, 0, time.Second)
r := chi.NewRouter()
r.Use(m)
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("body"))
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusCreated))
Expect(w.Header().Get("Content-Type")).To(Equal("image/jpeg"))
Expect(w.Header().Get("Cache-Control")).To(Equal("public"))
Expect(w.Body.String()).To(Equal("body"))
})
It("uses the first response status code", func() {
m := ThrottleBacklog(2, 0, time.Second)
r := chi.NewRouter()
r.Use(m)
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte("body"))
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusCreated))
Expect(w.Body.String()).To(Equal("body"))
})
It("never exceeds the concurrency limit", func() {
const limit = 3
const goroutines = 20
m := ThrottleBacklog(limit, goroutines, 5*time.Second)
var concurrent atomic.Int32
var maxConcurrent atomic.Int32
r := chi.NewRouter()
r.Use(m)
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
cur := concurrent.Add(1)
for {
old := maxConcurrent.Load()
if cur <= old || maxConcurrent.CompareAndSwap(old, cur) {
break
}
}
time.Sleep(5 * time.Millisecond)
concurrent.Add(-1)
_, _ = w.Write([]byte("ok"))
})
var wg sync.WaitGroup
for range goroutines {
wg.Go(func() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
})
}
wg.Wait()
Expect(maxConcurrent.Load()).To(BeNumerically("<=", limit))
})
// Regression: with only 1 token, a slow client blocking during response
// writing must NOT prevent other requests from being served. Chi's original
// ThrottleBacklog holds the token for the entire handler lifecycle including
// io.Copy, causing starvation. The buffered implementation releases it first.
Context("when a client is slow to read the response", func() {
slowClientTest := func(m func(http.Handler) http.Handler) (*chi.Mux, chan struct{}, chan struct{}) {
handlerReached := make(chan struct{}, 1)
router := chi.NewRouter()
router.Use(m)
router.Get("/test", func(w http.ResponseWriter, r *http.Request) {
select {
case handlerReached <- struct{}{}:
default:
}
_, _ = io.Copy(w, strings.NewReader("image data"))
})
unblocked := make(chan struct{})
slow := newSlowTestWriter(unblocked)
reqDone := make(chan struct{})
go func() {
defer close(reqDone)
req, _ := http.NewRequest("GET", "/test", nil)
router.ServeHTTP(slow, req)
}()
<-handlerReached
return router, unblocked, reqDone
}
It("does not starve concurrent requests with buffered middleware", func() {
router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond))
Eventually(func() int {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
router.ServeHTTP(w, req)
return w.Code
}, 2*time.Second, 10*time.Millisecond).Should(Equal(http.StatusOK))
close(unblocked)
Eventually(reqDone, 2*time.Second).Should(BeClosed())
})
It("starves concurrent requests with Chi's original middleware", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DevArtworkThrottleBuffered = false
router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond))
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
close(unblocked)
Eventually(reqDone, 2*time.Second).Should(BeClosed())
})
})
})
// runTwoRequests sends two concurrent requests through a throttled router. The
// first request holds the token until the second has been dispatched.
func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) {
held := make(chan struct{})
release := make(chan struct{})
r := chi.NewRouter()
r.Use(m)
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
select {
case held <- struct{}{}:
default:
}
<-release
_, _ = w.Write([]byte("ok"))
})
done := make(chan int)
go func() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
done <- w.Code
}()
<-held
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
secondStatus = w.Code
close(release)
firstStatus = <-done
return firstStatus, secondStatus
}
// slowTestWriter implements http.ResponseWriter without embedding
// httptest.ResponseRecorder. This is necessary because ResponseRecorder
// promotes io.ReaderFrom, which io.Copy prefers over Write — bypassing
// our blocking Write and defeating the slow-client simulation.
type slowTestWriter struct {
header http.Header
body bytes.Buffer
code int
unblocked chan struct{}
}
func newSlowTestWriter(unblocked chan struct{}) *slowTestWriter {
return &slowTestWriter{header: make(http.Header), unblocked: unblocked}
}
func (w *slowTestWriter) Header() http.Header { return w.header }
func (w *slowTestWriter) WriteHeader(code int) { w.code = code }
func (w *slowTestWriter) Write(p []byte) (int, error) {
<-w.unblocked
return w.body.Write(p)
}