feat(coverart): shared httpClient for provider HTTP plumbing (C1 prep)
This commit is contained in:
@@ -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<<attempt) * c.opts.BaseBackoff
|
||||
jitterRange := int64(base) / 5
|
||||
if jitterRange <= 0 {
|
||||
jitterRange = 1
|
||||
}
|
||||
jitter := time.Duration(rand.Int63n(jitterRange))
|
||||
if rand.Intn(2) == 0 {
|
||||
jitter = -jitter
|
||||
}
|
||||
d := base + jitter
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user