package artwork
import (
"cmp"
"context"
"errors"
"io"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"golang.org/x/time/rate"
)
const (
breakerThreshold = 5
breakerProbeAfter = time.Minute
breakerRecoveries = 3
)
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)
func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
return f()
}
func isTransientExternal(err error) bool {
return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound)
}
type extGate struct {
limiter *rate.Limiter
breaker *breaker
}
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
g := w.gateFor(name)
allowed, gen := g.breaker.allow()
if !allowed {
log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name)
return nil, "", errBreakerOpen
}
waitStart := time.Now()
if err := g.limiter.Wait(w.runCtx); err != nil {
return nil, "", err
}
callStart := time.Now()
r, path, err := f()
g.breaker.record(name, gen, err)
log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil,
"limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err)
return r, path, err
}
func (w *Worker) gateFor(name string) *extGate {
w.gatesMu.Lock()
defer w.gatesMu.Unlock()
if g, ok := w.gates[name]; ok {
return g
}
rps := conf.Server.DevArtworkExternalMaxRPS
limit := rate.Inf
if rps > 0 {
limit = rate.Limit(rps)
}
g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()}
w.gates[name] = g
return g
}
type breaker struct {
mu sync.Mutex
failures int
openedAt time.Time
recoveries int
generation int
probeAfter time.Duration
}
func newBreaker() *breaker { return &breaker{} }
func (b *breaker) allow() (bool, int) {
b.mu.Lock()
defer b.mu.Unlock()
if b.failures < breakerThreshold {
return true, 0
}
if time.Since(b.openedAt) >= cmp.Or(b.probeAfter, breakerProbeAfter) {
b.openedAt = time.Now()
return true, b.generation
}
return false, 0
}
func (b *breaker) record(name string, gen int, err error) {
if errors.Is(err, context.Canceled) {
return
}
b.mu.Lock()
defer b.mu.Unlock()
if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok && retry.RetryIn > 0 {
b.recoveries = 0
b.failures = breakerThreshold
b.openedAt = time.Now()
b.probeAfter = retry.RetryIn
b.generation++
log.Warn("Artwork: Circuit breaker opened for agent, provider asked to back off", "agent", name,
"probeAfter", retry.RetryIn)
return
}
if isTransientExternal(err) {
b.recoveries = 0
b.failures++
if b.failures == breakerThreshold {
b.openedAt = time.Now()
b.probeAfter = 0
b.generation++
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
}
return
}
if b.failures < breakerThreshold {
b.failures = 0
return
}
if gen == 0 || gen != b.generation {
return
}
b.recoveries++
if b.recoveries < breakerRecoveries {
return
}
log.Info("Artwork: Circuit breaker closed for agent", "agent", name,
"consecutiveAnswers", b.recoveries)
b.failures, b.recoveries = 0, 0
}