package coverart import ( "context" "encoding/json" "fmt" "io" "math/rand" "net/http" "sync" "time" ) // httpClientOptions configures a httpClient. Zero values get sensible // defaults: 3 retries, 250ms base backoff, 1MB JSON body limit, 5MB // image body limit, default http.Client with 30s timeout. type httpClientOptions struct { HTTPClient *http.Client // Name is used as the prefix in wrapped error messages // (e.g. "coverart lastfm: build request: ..."). Required. Name string // MinInterval throttles requests; 0 disables rate limiting. MinInterval time.Duration // MaxRetries is the number of retry attempts on 429 / 5xx. MaxRetries int // BaseBackoff is the base unit for exponential backoff; the // helper sleeps 2^attempt × BaseBackoff ± 20% jitter. BaseBackoff time.Duration // TreatAuthAsTransient maps 401/403 responses to ErrTransient // (vs. passing through as a generic non-2xx error). Providers // with API keys (lastfm, theaudiodb) want this; key-less // providers (deezer) leave it false. TreatAuthAsTransient bool // JSONBodyLimit and ImageBodyLimit cap the response body read // to protect against runaway upstreams. JSONBodyLimit int64 ImageBodyLimit int64 } // httpClient performs rate-limited, retrying GETs against an HTTP // JSON or image upstream. Shared by the cover-art providers — each // constructs its own client with provider-specific intervals and auth // semantics, then calls getJSON/getImage with fully-formed URLs. type httpClient struct { opts httpClientOptions mu sync.Mutex lastCall time.Time } func newHTTPClient(opts httpClientOptions) *httpClient { if opts.HTTPClient == nil { opts.HTTPClient = &http.Client{Timeout: 30 * time.Second} } if opts.MaxRetries == 0 { opts.MaxRetries = 3 } if opts.BaseBackoff == 0 { opts.BaseBackoff = 250 * time.Millisecond } if opts.JSONBodyLimit == 0 { opts.JSONBodyLimit = 1 * 1024 * 1024 } if opts.ImageBodyLimit == 0 { opts.ImageBodyLimit = 5 * 1024 * 1024 } return &httpClient{opts: opts} } // getJSON sends GET fullURL, decodes the 200 body into out (capped at // JSONBodyLimit). Retries 429/5xx with exponential backoff up to // MaxRetries. 404 → ErrNotFound. Non-2xx → wrapped ErrTransient. func (c *httpClient) getJSON(ctx context.Context, fullURL string, out any) error { for attempt := 0; attempt <= c.opts.MaxRetries; attempt++ { if err := c.rateLimitWait(ctx); err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) if err != nil { return fmt.Errorf("coverart %s: build request: %w", c.opts.Name, err) } req.Header.Set("Accept", "application/json") resp, err := c.opts.HTTPClient.Do(req) if err != nil { if attempt < c.opts.MaxRetries { if waitErr := c.backoff(ctx, attempt); waitErr != nil { return waitErr } continue } return fmt.Errorf("%w: %v", ErrTransient, err) } switch { case resp.StatusCode == http.StatusNotFound: _ = resp.Body.Close() return ErrNotFound case c.opts.TreatAuthAsTransient && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden): _ = resp.Body.Close() return fmt.Errorf("%w: status %d (auth)", ErrTransient, resp.StatusCode) case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: _ = resp.Body.Close() if attempt < c.opts.MaxRetries { if waitErr := c.backoff(ctx, attempt); waitErr != nil { return waitErr } continue } return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) case resp.StatusCode != http.StatusOK: _ = resp.Body.Close() return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, c.opts.JSONBodyLimit)) _ = resp.Body.Close() if err != nil { return fmt.Errorf("%w: read body: %v", ErrTransient, err) } if err := json.Unmarshal(body, out); err != nil { return fmt.Errorf("%w: decode body: %v", ErrTransient, err) } return nil } return fmt.Errorf("%w: exhausted retries", ErrTransient) } // getImage sends GET imgURL, returns the body bytes (capped at // ImageBodyLimit). 404 → ErrNotFound; non-2xx / network → ErrTransient. // No retry — image endpoints are typically CDN-backed and 429s are rare. func (c *httpClient) getImage(ctx context.Context, imgURL string) ([]byte, error) { if err := c.rateLimitWait(ctx); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil) if err != nil { return nil, fmt.Errorf("coverart %s: build request: %w", c.opts.Name, err) } req.Header.Set("Accept", "image/*") resp, err := c.opts.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrTransient, err) } defer func() { _ = resp.Body.Close() }() switch { case resp.StatusCode == http.StatusNotFound: return nil, ErrNotFound case resp.StatusCode != http.StatusOK: return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, c.opts.ImageBodyLimit)) if err != nil { return nil, fmt.Errorf("%w: read body: %v", ErrTransient, err) } return body, nil } // rateLimitWait blocks until at least MinInterval has elapsed since the // last call, then claims the slot. Disabled when MinInterval is 0. func (c *httpClient) rateLimitWait(ctx context.Context) error { if c.opts.MinInterval <= 0 { return nil } c.mu.Lock() wait := time.Until(c.lastCall.Add(c.opts.MinInterval)) if wait > 0 { timer := time.NewTimer(wait) c.mu.Unlock() select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: } c.mu.Lock() } c.lastCall = time.Now() c.mu.Unlock() return nil } // backoff sleeps for 2^attempt × BaseBackoff ± 20% jitter, or until ctx is done. func (c *httpClient) backoff(ctx context.Context, attempt int) error { base := time.Duration(1<