aboutsummaryrefslogtreecommitdiff
path: root/internal/bunny
diff options
context:
space:
mode:
Diffstat (limited to 'internal/bunny')
-rw-r--r--internal/bunny/api.go170
-rw-r--r--internal/bunny/api_delete.go46
-rw-r--r--internal/bunny/api_list.go152
-rw-r--r--internal/bunny/api_test.go349
-rw-r--r--internal/bunny/api_upload.go50
-rw-r--r--internal/bunny/error.go95
6 files changed, 862 insertions, 0 deletions
diff --git a/internal/bunny/api.go b/internal/bunny/api.go
new file mode 100644
index 0000000..1ffc1b5
--- /dev/null
+++ b/internal/bunny/api.go
@@ -0,0 +1,170 @@
+package bunny
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "tsne.dev/hopper/internal/log"
+)
+
+// Object is a single node returned by a directory listing.
+type Object struct {
+ Path string
+ Size int
+ IsDirectory bool
+ LastChanged time.Time
+}
+
+type API struct {
+ endpoint string // hostname only (normalized, no scheme/trailing slash)
+ zone string
+ accessKey string
+ maxRetries int
+ http *http.Client
+}
+
+func NewAPI(endpoint, zone, accessKey string) *API {
+ // normalize endpoint
+ endpoint = strings.TrimPrefix(endpoint, "https://")
+ endpoint = strings.TrimPrefix(endpoint, "http://")
+ endpoint = strings.TrimRight(endpoint, "/")
+
+ return &API{
+ endpoint: endpoint,
+ zone: zone,
+ accessKey: accessKey,
+ maxRetries: 3,
+ http: &http.Client{},
+ }
+}
+
+// do executes req under the shared retry policy and returns the final
+// response (2xx) or a typed *apiError. It owns the universal parts: the
+// AccessKey header, the 429+Retry-After retry loop, and status classification.
+// Callers own method, body, and op-specific headers, and must read/close the
+// returned body. method and path on any apiError are taken from req. The body
+// is reset from req.GetBody before each attempt; callers that send a body MUST
+// build the request with bytes.NewReader (so GetBody is set) or retries would
+// resend an empty body.
+func (api *API) do(ctx context.Context, req *http.Request) (*http.Response, error) {
+ req.Header.Set("AccessKey", api.accessKey)
+
+ attempt := 0
+ for {
+ attempt++
+ if req.GetBody != nil {
+ body, err := req.GetBody()
+ if err != nil {
+ return nil, newAPIError(errorKindNetwork, err, req, 0)
+ }
+ req.Body = body
+ }
+
+ resp, err := api.http.Do(req)
+ if err != nil {
+ if ctxErr := context.Cause(ctx); ctxErr != nil {
+ return nil, ctxErr
+ }
+ return nil, newAPIError(errorKindNetwork, err, req, 0)
+ }
+
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return resp, nil
+ }
+
+ bunnyMsg := readResponseError(resp.Body)
+ resp.Body.Close()
+
+ if resp.StatusCode == http.StatusTooManyRequests {
+ ra := resp.Header.Get("Retry-After")
+ if ra == "" {
+ return nil, newAPIError(errorKindRateLimited, errors.New("status 429, no retry-after header received"), req, 0)
+ }
+ if attempt > api.maxRetries {
+ return nil, newAPIError(errorKindRateLimited, errors.New("status 429, retries exhausted"), req, 0)
+ }
+ d, perr := parseRetryAfter(ra)
+ if perr != nil {
+ return nil, newAPIError(errorKindRateLimited, errors.New("status 429, "+perr.Error()), req, 0)
+ }
+ // Surface the cool-down so a long Retry-After wait doesn't look like a
+ // hang. Always warned (never suppressed): being rate-limited is
+ // abnormal and the wait is user-visible time.
+ log.Warn("rate-limited by Bunny (retry in %s; attempt %d/%d)", d.Round(time.Second), attempt, api.maxRetries)
+ select {
+ case <-ctx.Done():
+ return nil, context.Cause(ctx)
+ case <-time.After(d):
+ }
+ continue
+ }
+
+ errKind := errorKindFromStatus(resp.StatusCode)
+ var cause error
+ if bunnyMsg != "" {
+ cause = fmt.Errorf("http error %d: %s", resp.StatusCode, bunnyMsg)
+ } else {
+ cause = fmt.Errorf("http error %d", resp.StatusCode)
+ }
+ return nil, newAPIError(errKind, cause, req, resp.StatusCode)
+ }
+}
+
+// readResponseError best-effort extracts Bunny's ErrorObject.Message from a
+// non-2xx response body. It fully drains (bounded) the body so the connection
+// can be reused, and returns "" when the body is absent, unparseable, or
+// carries no Message. It never errors: enrichment is optional detail.
+func readResponseError(body io.Reader) string {
+ data, err := io.ReadAll(io.LimitReader(body, 8<<10)) // 8 KiB
+ if err != nil || len(data) == 0 {
+ return ""
+ }
+ var e struct {
+ Message string `json:"Message"`
+ }
+ if err := json.Unmarshal(data, &e); err != nil {
+ return ""
+ }
+ return strings.TrimSpace(e.Message)
+}
+
+func (api *API) url(path string) string {
+ return "https://" + api.endpoint + "/" + api.zone + "/" + path
+}
+
+// parseTime parses Bunny's LastChanged timestamp, which is NOT RFC 3339
+// (it carries no timezone, e.g. "2023-06-29T12:00:00.000"), so it cannot be
+// unmarshaled into a time.Time directly.
+func parseTime(v string) time.Time {
+ if v == "" {
+ return time.Time{}
+ }
+ if t, err := time.Parse("2006-01-02T15:04:05.999", v); err == nil {
+ return t.Local()
+ }
+ if t, err := time.Parse(time.RFC3339, v); err == nil {
+ return t.Local()
+ }
+ return time.Time{}
+}
+
+// parseRetryAfter handles the delay-seconds form and the HTTP-date form.
+func parseRetryAfter(v string) (time.Duration, error) {
+ if secs, err := strconv.Atoi(v); err == nil {
+ if secs < 0 {
+ return 0, fmt.Errorf("negative retry-after header")
+ }
+ return time.Duration(secs) * time.Second, nil
+ }
+ if t, err := http.ParseTime(v); err == nil {
+ return max(0, time.Until(t)), nil
+ }
+ return 0, fmt.Errorf("unparseable retry-after header %q", v)
+}
diff --git a/internal/bunny/api_delete.go b/internal/bunny/api_delete.go
new file mode 100644
index 0000000..868b0f3
--- /dev/null
+++ b/internal/bunny/api_delete.go
@@ -0,0 +1,46 @@
+package bunny
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+)
+
+// Delete removes a single object with a raw DELETE, applying the shared retry
+// policy.
+func (api *API) Delete(ctx context.Context, path string) error {
+ return api.delete(ctx, api.url(path))
+}
+
+// DeleteDir removes a directory. Bunny's storage endpoints identify a directory
+// by a trailing slash on the URL (the same quirk listDir relies on), so we
+// append it here rather than leak the convention to callers. The delete is
+// recursive.
+//
+// CAUTION: The delete is recursive AND prune decides emptiness from the
+// pre-delete listing snapshot. If another actor uploads a file under this directory
+// between the snapshot and this call, the recursive delete will remove it.
+func (api *API) DeleteDir(ctx context.Context, path string) error {
+ u := api.url(path)
+ if !strings.HasSuffix(u, "/") {
+ u += "/"
+ }
+ return api.delete(ctx, u)
+}
+
+func (api *API) delete(ctx context.Context, u string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+
+ resp, err := api.do(ctx, req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ return nil
+}
diff --git a/internal/bunny/api_list.go b/internal/bunny/api_list.go
new file mode 100644
index 0000000..57a7817
--- /dev/null
+++ b/internal/bunny/api_list.go
@@ -0,0 +1,152 @@
+package bunny
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "tsne.dev/hopper/internal/log"
+)
+
+// Iterator is an old-school pull iterator over the recursive remote listing under
+// a starting directory. It yields every entry (files AND directories; a
+// directory has Size 0) so consumers can build whatever structure they need:
+// push collects files and verifies no file collides with a remote directory;
+// prune derives orphans and empty directories. Usage:
+//
+// it := client.List(ctx, dir)
+// for it.Next() {
+// rec := it.Object()
+// }
+// if err := it.Err(); err != nil { ... }
+//
+// The walk is iterative: a stack holds directories still to list, each listed
+// on demand. A 404 listing is an empty subtree (skipped, not fatal); any other
+// listing failure stops iteration and surfaces via Err.
+type Iterator struct {
+ ctx context.Context
+ listDir func(context.Context, string) ([]Object, error)
+ stack []string
+ pending []Object
+ cur Object
+ err error
+ done bool
+}
+
+// NewIterator builds an Iterator rooted at dir, listing each directory via listDir.
+func NewIterator(ctx context.Context, dir string, listDir func(context.Context, string) ([]Object, error)) *Iterator {
+ stack := make([]string, 0, 4)
+ stack = append(stack, dir)
+ return &Iterator{ctx: ctx, listDir: listDir, stack: stack}
+}
+
+// Next advances to the next entry, returning false when the walk is exhausted
+// or a listing failed (check Err). Directory entries are yielded and also queued
+// for descent.
+func (it *Iterator) Next() bool {
+ if it.err != nil || it.done {
+ return false
+ }
+ for {
+ if len(it.pending) > 0 {
+ o := it.pending[0]
+ it.pending = it.pending[1:]
+ if o.IsDirectory {
+ it.stack = append(it.stack, o.Path)
+ }
+ it.cur = o
+ return true
+ }
+ if len(it.stack) == 0 {
+ it.done = true
+ return false
+ }
+ dir := it.stack[len(it.stack)-1]
+ it.stack = it.stack[:len(it.stack)-1]
+ objs, err := it.listDir(it.ctx, dir)
+ if err != nil {
+ var apiErr *apiError
+ if errors.As(err, &apiErr) && apiErr.status == http.StatusNotFound {
+ continue // 404 = empty subtree
+ }
+ it.err = err
+ return false
+ }
+ it.pending = objs
+ }
+}
+
+// Object returns the entry reached by the most recent Next.
+func (it *Iterator) Object() Object {
+ return it.cur
+}
+
+// Err returns the listing failure that stopped iteration, if any.
+func (it *Iterator) Err() error {
+ return it.err
+}
+
+// List returns an Iterator over the recursive listing rooted at dir. An empty
+// dir denotes zone root.
+func (api *API) List(ctx context.Context, dir string) *Iterator {
+ return NewIterator(ctx, dir, api.listDir)
+}
+
+// listDir returns the immediate children of dir. Bunny's list endpoint is
+// per-directory and expects a trailing slash on the directory URL. Returned
+// Object.Path is the full storage key relative to the zone (matching source
+// keys), so the skip-check can match it.
+func (api *API) listDir(ctx context.Context, dir string) ([]Object, error) {
+ if dir == "" {
+ log.PrintVerbose("listing zone root")
+ } else {
+ log.PrintVerbose("listing %s", dir)
+ }
+
+ u := api.url(dir)
+ if !strings.HasSuffix(u, "/") {
+ u += "/"
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build request: %w", err)
+ }
+
+ resp, err := api.do(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var raw []listObject
+ if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
+ return nil, newAPIError(errorKindInternal, errors.New("failed to decode list response ("+err.Error()+")"), req, resp.StatusCode)
+ }
+
+ prefix := "/" + api.zone + "/"
+ objs := make([]Object, len(raw))
+ for i, o := range raw {
+ objs[i] = Object{
+ Path: strings.TrimPrefix(o.Path, prefix) + o.ObjectName,
+ Size: o.Length,
+ IsDirectory: o.IsDirectory,
+ LastChanged: parseTime(o.LastChanged),
+ }
+ }
+ return objs, nil
+}
+
+// listObject mirrors a single entry of Bunny's per-directory list response. The
+// full storage key is Path+ObjectName (Path includes the zone and trailing
+// slash); see List.
+type listObject struct {
+ Path string `json:"Path"`
+ ObjectName string `json:"ObjectName"`
+ Length int `json:"Length"`
+ LastChanged string `json:"LastChanged"`
+ IsDirectory bool `json:"IsDirectory"`
+}
diff --git a/internal/bunny/api_test.go b/internal/bunny/api_test.go
new file mode 100644
index 0000000..e74a3b9
--- /dev/null
+++ b/internal/bunny/api_test.go
@@ -0,0 +1,349 @@
+package bunny
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+func newTestAPI(srvURL string) *API {
+ api := NewAPI(srvURL, "zone", "key")
+ api.http = &http.Client{Transport: rewriteTransport{}}
+ return api
+}
+
+// rewriteTransport downgrades https://host/... to http://host/... for tests.
+type rewriteTransport struct{}
+
+func (rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req.URL.Scheme = "http"
+ return http.DefaultTransport.RoundTrip(req)
+}
+
+func TestUploadSuccess(t *testing.T) {
+ var gotKey, gotChecksum, gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotKey = r.Header.Get("AccessKey")
+ gotChecksum = r.Header.Get("Checksum")
+ gotPath = r.URL.Path
+ if r.Header.Get("Content-Type") != "" {
+ t.Errorf("unexpected Content-Type %q", r.Header.Get("Content-Type"))
+ }
+ w.WriteHeader(201)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "a/b.js", []byte("data")); err != nil {
+ t.Fatalf("Upload: %v", err)
+ }
+ sum := sha256.Sum256([]byte("data"))
+ wantChecksum := strings.ToUpper(hex.EncodeToString(sum[:]))
+ if gotKey != "key" || gotChecksum != wantChecksum {
+ t.Fatalf("headers: key=%q checksum=%q (want %q)", gotKey, gotChecksum, wantChecksum)
+ }
+ if gotPath != "/zone/a/b.js" {
+ t.Fatalf("path = %q", gotPath)
+ }
+}
+
+func TestUploadRetriesOn429WithRetryAfter(t *testing.T) {
+ var n int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if atomic.AddInt32(&n, 1) <= 2 {
+ w.Header().Set("Retry-After", "0")
+ w.WriteHeader(429)
+ return
+ }
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "x", []byte("d")); err != nil {
+ t.Fatalf("Upload: %v", err)
+ }
+ if n != 3 {
+ t.Fatalf("attempts = %d, want 3", n)
+ }
+}
+
+func TestUploadFailsOn429NoRetryAfter(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(429)
+ }))
+ defer srv.Close()
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestUploadFailsOn5xx(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ }))
+ defer srv.Close()
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestUploadRetriesExhausted(t *testing.T) {
+ var n int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&n, 1)
+ w.Header().Set("Retry-After", "0")
+ w.WriteHeader(429)
+ }))
+ defer srv.Close()
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
+ t.Fatal("expected error")
+ }
+ if n != 4 { // initial + 3 retries
+ t.Fatalf("attempts = %d, want 4", n)
+ }
+}
+
+func TestUploadClassifiesStatus(t *testing.T) {
+ cases := []struct {
+ status int
+ kind errorKind
+ }{
+ {http.StatusUnauthorized, errorKindAuth},
+ {http.StatusForbidden, errorKindAuth},
+ {http.StatusBadRequest, errorKindInternal},
+ {http.StatusNotFound, errorKindInternal},
+ {http.StatusInternalServerError, errorKindServer},
+ {http.StatusBadGateway, errorKindServer},
+ }
+ for _, tc := range cases {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.status)
+ }))
+ api := newTestAPI(srv.URL)
+ err := api.Upload(context.Background(), "x", []byte("d"))
+ srv.Close()
+
+ var apiErr *apiError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("status %d: want *APIError, got %T (%v)", tc.status, err, err)
+ }
+ if apiErr.kind != tc.kind {
+ t.Fatalf("status %d: kind = %d, want %d", tc.status, apiErr.kind, tc.kind)
+ }
+ if apiErr.status != tc.status {
+ t.Fatalf("status %d: APIError.Status = %d", tc.status, apiErr.status)
+ }
+ }
+}
+
+func TestUploadSurfacesBunnyErrorMessage(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"HttpCode":400,"Message":"checksum mismatch"}`))
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ err := api.Upload(context.Background(), "x", []byte("d"))
+
+ var apiErr *apiError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("want *apiError, got %T (%v)", err, err)
+ }
+ if got := apiErr.ErrorVerbose(); !strings.Contains(got, "checksum mismatch") {
+ t.Fatalf("verbose detail = %q, want it to contain Bunny's Message", got)
+ }
+}
+
+func TestListParsesFullPaths(t *testing.T) {
+ var gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.Write([]byte(`[
+ {"Path":"/zone/assets/","ObjectName":"app.abc.js","Length":14,"IsDirectory":false,"LastChanged":"2023-06-29T12:00:00.000"},
+ {"Path":"/zone/assets/","ObjectName":"sub","Length":0,"IsDirectory":true,"LastChanged":"2023-06-29T12:00:00"}
+ ]`))
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ objs, err := api.listDir(context.Background(), "assets")
+ if err != nil {
+ t.Fatalf("listDir: %v", err)
+ }
+ if gotPath != "/zone/assets/" {
+ t.Fatalf("list path = %q, want trailing slash", gotPath)
+ }
+ if len(objs) != 2 {
+ t.Fatalf("objs = %v", objs)
+ }
+ if objs[0].Path != "assets/app.abc.js" || objs[0].Size != 14 || objs[0].IsDirectory {
+ t.Fatalf("file object = %+v", objs[0])
+ }
+ if objs[1].Path != "assets/sub" || !objs[1].IsDirectory {
+ t.Fatalf("dir object = %+v", objs[1])
+ }
+}
+
+func TestListRootTrailingSlash(t *testing.T) {
+ var gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.Write([]byte(`[]`))
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if _, err := api.listDir(context.Background(), ""); err != nil {
+ t.Fatalf("listDir: %v", err)
+ }
+ if gotPath != "/zone/" {
+ t.Fatalf("root list path = %q, want /zone/", gotPath)
+ }
+}
+
+func TestWalkBuildsTreeAndDescends(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/zone/":
+ w.Write([]byte(`[
+ {"Path":"/zone/","ObjectName":"index.html","Length":6,"IsDirectory":false},
+ {"Path":"/zone/","ObjectName":"assets","IsDirectory":true}
+ ]`))
+ case "/zone/assets/":
+ w.Write([]byte(`[{"Path":"/zone/assets/","ObjectName":"app.js","Length":3,"IsDirectory":false}]`))
+ default:
+ t.Errorf("unexpected list path %q", r.URL.Path)
+ }
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ it := api.List(context.Background(), "")
+ var got []Object
+ for it.Next() {
+ got = append(got, it.Object())
+ }
+ if err := it.Err(); err != nil {
+ t.Fatalf("Walk: %v", err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("got %d entries: %+v", len(got), got)
+ }
+ var sawDir, sawNested bool
+ for _, o := range got {
+ if o.Path == "assets" && o.IsDirectory {
+ sawDir = true
+ }
+ if o.Path == "assets/app.js" && !o.IsDirectory && o.Size == 3 {
+ sawNested = true
+ }
+ }
+ if !sawDir || !sawNested {
+ t.Fatalf("expected descent into assets; got %+v", got)
+ }
+}
+
+func TestWalk404IsEmpty(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(404)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ it := api.List(context.Background(), "")
+ for it.Next() {
+ t.Fatalf("unexpected entry %+v", it.Object())
+ }
+ if err := it.Err(); err != nil {
+ t.Fatalf("Walk: %v", err)
+ }
+}
+
+func TestWalkFatalOnNon404(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ it := api.List(context.Background(), "")
+ for it.Next() {
+ }
+ if it.Err() == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestUploadRateLimitedIsTyped(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(429)
+ }))
+ defer srv.Close()
+ api := newTestAPI(srv.URL)
+ err := api.Upload(context.Background(), "x", []byte("d"))
+ var apiErr *apiError
+ if !errors.As(err, &apiErr) || apiErr.kind != errorKindRateLimited {
+ t.Fatalf("want KindRateLimited APIError, got %v", err)
+ }
+}
+
+func TestDeleteSuccess(t *testing.T) {
+ var gotMethod, gotKey, gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ gotKey = r.Header.Get("AccessKey")
+ gotPath = r.URL.Path
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if err := api.Delete(context.Background(), "a/b.js"); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ if gotMethod != http.MethodDelete {
+ t.Fatalf("method = %q", gotMethod)
+ }
+ if gotKey != "key" || gotPath != "/zone/a/b.js" {
+ t.Fatalf("key=%q path=%q", gotKey, gotPath)
+ }
+}
+
+func TestDeleteFailsOn5xx(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if err := api.Delete(context.Background(), "a/b.js"); err == nil {
+ t.Fatal("expected error on 5xx")
+ }
+}
+
+// TestAPIErrorUnwrapsCause ensures a network apiError preserves its cause in the
+// error chain, so errors.Is can detect e.g. context.Canceled from an aborted
+// in-flight upload. Without this, the worker pool cannot filter cancellation
+// collateral out of a fail-fast failure report.
+func TestAPIErrorUnwrapsCause(t *testing.T) {
+ req, err := http.NewRequest(http.MethodPut, "https://example.test/zone/a.js", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Simulate a transport error whose cause is context.Canceled.
+ apiErr := newAPIError(errorKindNetwork, context.Canceled, req, 0)
+ if !errors.Is(apiErr, context.Canceled) {
+ t.Fatalf("apiError must unwrap to its cause; got chain that is not context.Canceled")
+ }
+}
diff --git a/internal/bunny/api_upload.go b/internal/bunny/api_upload.go
new file mode 100644
index 0000000..f2af534
--- /dev/null
+++ b/internal/bunny/api_upload.go
@@ -0,0 +1,50 @@
+package bunny
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "io"
+ "net/http"
+ "unsafe"
+)
+
+// Upload performs a raw PUT with AccessKey and Checksum headers, applying the
+// retry policy: a 429 with Retry-After is retried up to maxRetries times,
+// honoring the header; every other non-2xx (429 without Retry-After, 5xx,
+// other 4xx) and any network error fails. The Checksum header is the
+// hex-encoded SHA256 of body, computed here so callers need not know Bunny's
+// integrity scheme.
+func (api *API) Upload(ctx context.Context, path string, body []byte) error {
+ sum := sha256.Sum256(body)
+ checksum := upperHexEncode(sum[:])
+
+ // bytes.NewReader lets net/http set req.GetBody automatically, so do can
+ // replay the body on each retry attempt.
+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, api.url(path), bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ req.Header.Set("Checksum", checksum)
+ req.ContentLength = int64(len(body))
+
+ resp, err := api.do(ctx, req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ return nil
+}
+
+func upperHexEncode(src []byte) string {
+ const alphabet = "0123456789ABCDEF"
+
+ dst := make([]byte, len(src)*2)
+ for i, b := range src {
+ dst[i*2] = alphabet[b>>4]
+ dst[i*2+1] = alphabet[b&0x0f]
+ }
+ return unsafe.String(unsafe.SliceData(dst), len(dst))
+}
diff --git a/internal/bunny/error.go b/internal/bunny/error.go
new file mode 100644
index 0000000..4c17bda
--- /dev/null
+++ b/internal/bunny/error.go
@@ -0,0 +1,95 @@
+package bunny
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type errorKind int
+
+const (
+ // errorKindNetwork: the request never got a response (DNS, connection, TLS,
+ // context cancellation surfaced as a transport error).
+ errorKindNetwork errorKind = iota
+ // errorKindAuth: the access key was rejected (401/403).
+ errorKindAuth
+ // errorKindRateLimited: Bunny is throttling (429, in any retry-exhausted form).
+ errorKindRateLimited
+ // errorKindServer: Bunny-side failure (5xx).
+ errorKindServer
+ // errorKindInternal: an unexpected client error (non-auth, non-rate-limit 4xx).
+ // These almost always mean hopper built a bad request: a likely bug.
+ errorKindInternal
+ // errorKindTooLarge: Bunny rejected the asset as too large (413). This is the
+ // operator's payload, not a hopper bug, so it gets a distinct message.
+ errorKindTooLarge
+)
+
+func errorKindFromStatus(status int) errorKind {
+ switch {
+ case status == http.StatusUnauthorized || status == http.StatusForbidden:
+ return errorKindAuth
+ case status == http.StatusRequestEntityTooLarge:
+ return errorKindTooLarge
+ case status >= 500:
+ return errorKindServer
+ default:
+ return errorKindInternal
+ }
+}
+
+// apiError is the single error type returned by all API operations. Error()
+// renders a friendly, kind-based message for the user; the technical detail
+// (method, path, status, wrapped cause) is exposed separately via
+// ErrorVerbose() and surfaced only under --verbose by the presentation layer.
+// method and path are derived from the request itself (HTTP verb and
+// req.URL.Path).
+type apiError struct {
+ method string // HTTP verb, e.g. "PUT"
+ path string // req.URL.Path, e.g. "/zone/a/b.js"
+ status int // HTTP status code; 0 for transport-level (KindNetwork) errors
+ kind errorKind
+ cause error // wrapped technical cause, exposed via Unwrap (NOT via Error)
+}
+
+func newAPIError(kind errorKind, err error, req *http.Request, statusCode int) *apiError {
+ return &apiError{
+ method: req.Method,
+ path: req.URL.Path,
+ status: statusCode,
+ kind: kind,
+ cause: err,
+ }
+}
+
+func (e *apiError) Unwrap() error { return e.cause }
+
+// ErrorVerbose returns the technical description (verb, path, status, cause)
+// surfaced only under --verbose. It is the "re-run with --verbose for details"
+// payload; the user-facing message stays in Error(). Any error type may
+// implement this interface to contribute extra detail to failure reports.
+func (e *apiError) ErrorVerbose() string {
+ if e.status != 0 {
+ return fmt.Sprintf("%s %s -> %d: %v", e.method, e.path, e.status, e.cause)
+ }
+ return fmt.Sprintf("%s %s -> %v", e.method, e.path, e.cause)
+}
+
+func (e *apiError) Error() string {
+ switch e.kind {
+ case errorKindAuth:
+ return "The access key was rejected. Check your Bunny storage credentials."
+ case errorKindRateLimited:
+ return "Bunny is rate-limiting requests. Please try again shortly."
+ case errorKindServer:
+ return "Bunny had an internal server error. Please try again later."
+ case errorKindNetwork:
+ return "Couldn't reach Bunny. Check your network connection and endpoint."
+ case errorKindTooLarge:
+ return "The asset was rejected by Bunny as too large."
+ case errorKindInternal:
+ return "Unexpected response from Bunny. This is likely a bug in hopper. Please report it (re-run with --verbose for details)."
+ default:
+ return "An unknown Bunny error occurred (re-run with --verbose for details)."
+ }
+}