aboutsummaryrefslogtreecommitdiff
path: root/internal/bunny/api.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/bunny/api.go')
-rw-r--r--internal/bunny/api.go170
1 files changed, 170 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)
+}