navidrome/server/throttle_backlog.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

151 lines
3.6 KiB
Go

package server
import (
"bytes"
"context"
"errors"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
var (
ErrThrottleCapacityExceeded = errors.New("throttle: capacity exceeded")
ErrThrottleTimeout = errors.New("throttle: backlog timeout")
)
type requestThrottle struct {
tokens chan struct{}
backlogTokens chan struct{}
backlogTimeout time.Duration
}
// ThrottleBacklog creates a Chi-compatible middleware that limits concurrent
// request processing. Unlike Chi's ThrottleBacklog, it buffers the handler's
// response while holding the token, releases it, then flushes the buffer to
// the client with a write deadline. This prevents slow clients from holding
// throttle capacity.
//
// Because it buffers the entire response in memory, this middleware should only
// be used for endpoints that return small responses (e.g., artwork images). Do
// not use it for audio streaming or download endpoints.
func ThrottleBacklog(limit, backlogLimit int, backlogTimeout time.Duration) func(http.Handler) http.Handler {
if limit <= 0 {
return func(next http.Handler) http.Handler { return next }
}
if !conf.Server.DevArtworkThrottleBuffered {
return middleware.ThrottleBacklog(limit, backlogLimit, backlogTimeout)
}
t := &requestThrottle{
tokens: make(chan struct{}, limit),
backlogTokens: make(chan struct{}, limit+backlogLimit),
backlogTimeout: backlogTimeout,
}
for range limit {
t.tokens <- struct{}{}
}
for range limit + backlogLimit {
t.backlogTokens <- struct{}{}
}
return t.handler
}
func (t *requestThrottle) handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
release, err := t.acquire(ctx)
if err != nil {
switch {
case errors.Is(err, ErrThrottleCapacityExceeded):
log.Warn(ctx, "Request throttle capacity exceeded", "path", r.URL.Path)
case errors.Is(err, ErrThrottleTimeout):
log.Warn(ctx, "Request throttle backlog timeout", "path", r.URL.Path)
}
http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
return
}
buf := &bufferedResponseWriter{header: make(http.Header)}
func() {
defer release()
next.ServeHTTP(buf, r)
}()
for k, v := range buf.header {
w.Header()[k] = v
}
if buf.code > 0 {
w.WriteHeader(buf.code)
}
if _, err := w.Write(buf.body.Bytes()); err != nil {
log.Warn(ctx, "Error writing throttled response", err)
}
})
}
func (t *requestThrottle) acquire(ctx context.Context) (release func(), err error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-t.backlogTokens:
default:
return nil, ErrThrottleCapacityExceeded
}
select {
case <-t.tokens:
return t.releaseFunc(), nil
default:
}
timer := time.NewTimer(t.backlogTimeout)
select {
case <-timer.C:
t.backlogTokens <- struct{}{}
return nil, ErrThrottleTimeout
case <-ctx.Done():
timer.Stop()
t.backlogTokens <- struct{}{}
return nil, ctx.Err()
case <-t.tokens:
timer.Stop()
return t.releaseFunc(), nil
}
}
func (t *requestThrottle) releaseFunc() func() {
var once sync.Once
return func() {
once.Do(func() {
t.tokens <- struct{}{}
t.backlogTokens <- struct{}{}
})
}
}
type bufferedResponseWriter struct {
header http.Header
body bytes.Buffer
code int
}
func (w *bufferedResponseWriter) Header() http.Header {
return w.header
}
func (w *bufferedResponseWriter) Write(b []byte) (int, error) {
return w.body.Write(b)
}
func (w *bufferedResponseWriter) WriteHeader(code int) {
if w.code != 0 {
return
}
w.code = code
}