- Mirror pool with health tracking and exponential backoff for failed hosts - Agent client routes requests through mirror pool with retry semantics - New `unarr mirrors` command to inspect mirror state and force failover - `unarr status` now detects 401 from /agent/register and suggests `unarr login` instead of the generic "Could not fetch account info" message - Config supports multiple ScanPaths for upcoming multi-path library scan - Draft plan for bidirectional library sync (CLI ↔ Web) under Docs/plans/
172 lines
4.7 KiB
Go
172 lines
4.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// MirrorPool holds the ordered list of API base URLs the client is willing to
|
|
// fall back to when the current mirror is unreachable. The first entry is
|
|
// always the "preferred" mirror configured by the user. Subsequent entries
|
|
// are alternate domains we can rotate to without changing any user-visible
|
|
// configuration — they exist so a long-lived agent survives a takedown of
|
|
// the primary host without needing a new release.
|
|
//
|
|
// The pool is concurrency-safe; rotation is a fast O(1) index bump under a
|
|
// mutex. The previously-active mirror is NEVER removed — it might just be
|
|
// temporarily unreachable from one network path.
|
|
type MirrorPool struct {
|
|
mu sync.RWMutex
|
|
mirrors []string
|
|
current int
|
|
}
|
|
|
|
// NewMirrorPool builds a pool from the provided base URLs. The primary URL
|
|
// is always first; "extras" are appended in order and de-duplicated. Empty
|
|
// strings are skipped. Trailing slashes are normalised so callers can concat
|
|
// `pool.Current() + "/api/..."` reliably.
|
|
func NewMirrorPool(primary string, extras []string) *MirrorPool {
|
|
seen := make(map[string]struct{})
|
|
var out []string
|
|
|
|
add := func(raw string) {
|
|
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
|
if raw == "" {
|
|
return
|
|
}
|
|
if _, dup := seen[raw]; dup {
|
|
return
|
|
}
|
|
seen[raw] = struct{}{}
|
|
out = append(out, raw)
|
|
}
|
|
|
|
add(primary)
|
|
for _, e := range extras {
|
|
add(e)
|
|
}
|
|
|
|
if len(out) == 0 {
|
|
// Defensive: always return a pool with at least one entry so callers
|
|
// can call Current() without nil checks. The empty string would
|
|
// produce obvious errors immediately, which is preferable to a panic
|
|
// somewhere deep in net/http.
|
|
out = []string{""}
|
|
}
|
|
|
|
return &MirrorPool{mirrors: out}
|
|
}
|
|
|
|
// Current returns the active base URL.
|
|
func (p *MirrorPool) Current() string {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return p.mirrors[p.current]
|
|
}
|
|
|
|
// Mirrors returns a copy of the configured base URLs in priority order.
|
|
func (p *MirrorPool) Mirrors() []string {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
out := make([]string, len(p.mirrors))
|
|
copy(out, p.mirrors)
|
|
return out
|
|
}
|
|
|
|
// Len reports how many mirrors are configured.
|
|
func (p *MirrorPool) Len() int {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return len(p.mirrors)
|
|
}
|
|
|
|
// Rotate moves the cursor to the next mirror in the pool, wrapping around.
|
|
// Returns the new current mirror and whether a rotation actually happened
|
|
// (a single-mirror pool returns false).
|
|
func (p *MirrorPool) Rotate() (string, bool) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if len(p.mirrors) <= 1 {
|
|
return p.mirrors[p.current], false
|
|
}
|
|
p.current = (p.current + 1) % len(p.mirrors)
|
|
return p.mirrors[p.current], true
|
|
}
|
|
|
|
// Replace swaps the entire mirror set, e.g. after `unarr mirrors update`
|
|
// downloaded a fresh list from /api/v1/mirrors. Resets the cursor to 0 so
|
|
// the newly-discovered primary is tried first.
|
|
func (p *MirrorPool) Replace(primary string, extras []string) {
|
|
fresh := NewMirrorPool(primary, extras)
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.mirrors = fresh.mirrors
|
|
p.current = 0
|
|
}
|
|
|
|
// IsTransient reports whether an error is the kind we should retry against
|
|
// another mirror. The intent is conservative: rotate on connection-level
|
|
// failures (DNS, refused, TLS, timeouts, 5xx) but NOT on auth or validation
|
|
// errors that would just fail again somewhere else.
|
|
func IsTransient(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var httpErr *HTTPError
|
|
if errors.As(err, &httpErr) {
|
|
switch httpErr.StatusCode {
|
|
case http.StatusBadGateway,
|
|
http.StatusServiceUnavailable,
|
|
http.StatusGatewayTimeout,
|
|
http.StatusRequestTimeout:
|
|
return true
|
|
}
|
|
// 4xx (auth, rate limit, validation) won't get healthier on another mirror.
|
|
return false
|
|
}
|
|
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return true
|
|
}
|
|
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
return true
|
|
}
|
|
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) {
|
|
return true
|
|
}
|
|
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) {
|
|
// `connection refused`, `EOF`, `tls: ...` end up as wrapped url.Errors.
|
|
msg := urlErr.Error()
|
|
if strings.Contains(msg, "connection refused") ||
|
|
strings.Contains(msg, "no such host") ||
|
|
strings.Contains(msg, "EOF") ||
|
|
strings.Contains(msg, "tls:") ||
|
|
strings.Contains(msg, "i/o timeout") ||
|
|
strings.Contains(msg, "network is unreachable") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Bare strings as last resort — net.OpError messages are unstable across Go versions.
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "connection refused") ||
|
|
strings.Contains(msg, "no such host") ||
|
|
strings.Contains(msg, "i/o timeout") ||
|
|
strings.Contains(msg, "network is unreachable") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|