diff options
| author | tsne <tsne.dev@outlook.com> | 2026-07-06 16:52:20 +0200 |
|---|---|---|
| committer | tsne <tsne.dev@outlook.com> | 2026-07-06 21:37:42 +0200 |
| commit | 84da43998f7cc42cc72652333ac6e3d293abb23e (patch) | |
| tree | 6610f82df854ae86f7f34152bb05fc712ef6f314 /internal/bunny | |
| download | hopper-main.tar.gz | |
Diffstat (limited to 'internal/bunny')
| -rw-r--r-- | internal/bunny/api.go | 170 | ||||
| -rw-r--r-- | internal/bunny/api_delete.go | 46 | ||||
| -rw-r--r-- | internal/bunny/api_list.go | 152 | ||||
| -rw-r--r-- | internal/bunny/api_test.go | 349 | ||||
| -rw-r--r-- | internal/bunny/api_upload.go | 50 | ||||
| -rw-r--r-- | internal/bunny/error.go | 95 |
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)." + } +} |