feat(coverart): shared httpClient for provider HTTP plumbing (C1 prep)

This commit is contained in:
2026-05-08 07:51:33 -04:00
parent 6d16aa2de4
commit b4edda5518
2 changed files with 419 additions and 0 deletions
+207
View File
@@ -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
}
}
+212
View File
@@ -0,0 +1,212 @@
package coverart
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestHTTPClient_GetJSON_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"alice"}`))
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{Name: "test"})
var got struct{ Name string }
if err := c.getJSON(context.Background(), srv.URL, &got); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got.Name != "alice" {
t.Errorf("Name = %q, want alice", got.Name)
}
}
func TestHTTPClient_GetJSON_404IsNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{Name: "test"})
var out any
err := c.getJSON(context.Background(), srv.URL, &out)
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestHTTPClient_GetJSON_RetryOn429ThenSuccess(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n := calls.Add(1)
if n < 3 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{
Name: "test",
MaxRetries: 3,
BaseBackoff: 1 * time.Millisecond, // tiny so the test is fast
})
var got struct{ OK bool }
if err := c.getJSON(context.Background(), srv.URL, &got); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if calls.Load() != 3 {
t.Errorf("calls = %d, want 3", calls.Load())
}
if !got.OK {
t.Errorf("OK = false, want true")
}
}
func TestHTTPClient_GetJSON_RetryOn5xxThenExhausted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{
Name: "test",
MaxRetries: 2,
BaseBackoff: 1 * time.Millisecond,
})
var out any
err := c.getJSON(context.Background(), srv.URL, &out)
if !errors.Is(err, ErrTransient) {
t.Errorf("err = %v, want ErrTransient", err)
}
}
func TestHTTPClient_GetJSON_AuthAsTransient(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{
Name: "test",
TreatAuthAsTransient: true,
})
var out any
err := c.getJSON(context.Background(), srv.URL, &out)
if !errors.Is(err, ErrTransient) {
t.Errorf("err = %v, want ErrTransient", err)
}
}
func TestHTTPClient_GetJSON_AuthPassthroughByDefault(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{Name: "test"})
var out any
err := c.getJSON(context.Background(), srv.URL, &out)
if !errors.Is(err, ErrTransient) {
// 401 is non-2xx so it falls into the generic transient bucket.
t.Errorf("err = %v, want ErrTransient (generic non-2xx)", err)
}
}
func TestHTTPClient_GetJSON_MalformedBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`not json`))
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{Name: "test"})
var got struct{ Name string }
err := c.getJSON(context.Background(), srv.URL, &got)
if !errors.Is(err, ErrTransient) {
t.Errorf("err = %v, want ErrTransient", err)
}
}
func TestHTTPClient_GetJSON_ContextCancel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{
Name: "test",
MaxRetries: 5,
BaseBackoff: 200 * time.Millisecond,
})
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
var out any
err := c.getJSON(ctx, srv.URL, &out)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("err = %v, want context.DeadlineExceeded", err)
}
}
func TestHTTPClient_GetImage_Success(t *testing.T) {
want := []byte("\x89PNG\x0d\x0a\x1a\x0a-fake-png-bytes")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(want)
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{Name: "test"})
got, err := c.getImage(context.Background(), srv.URL)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if string(got) != string(want) {
t.Errorf("body mismatch")
}
}
func TestHTTPClient_GetImage_404IsNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{Name: "test"})
_, err := c.getImage(context.Background(), srv.URL)
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestHTTPClient_RateLimit(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(struct{ OK bool }{true})
}))
defer srv.Close()
c := newHTTPClient(httpClientOptions{
Name: "test",
MinInterval: 50 * time.Millisecond,
})
var out struct{ OK bool }
t0 := time.Now()
if err := c.getJSON(context.Background(), srv.URL, &out); err != nil {
t.Fatalf("first call: %v", err)
}
if err := c.getJSON(context.Background(), srv.URL, &out); err != nil {
t.Fatalf("second call: %v", err)
}
elapsed := time.Since(t0)
if elapsed < 50*time.Millisecond {
t.Errorf("elapsed %v, want >= 50ms (rate limit)", elapsed)
}
}