diff --git a/internal/coverart/httpclient.go b/internal/coverart/httpclient.go new file mode 100644 index 00000000..b9023dff --- /dev/null +++ b/internal/coverart/httpclient.go @@ -0,0 +1,207 @@ +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<= 50ms (rate limit)", elapsed) + } +}