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))
})
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())
})
})
})
func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) {
held := make(chan struct{}, 1)
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
}
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)
}