mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-02 07:01:36 +00:00
* 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>
151 lines
3.6 KiB
Go
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
|
|
}
|