diff options
Diffstat (limited to 'internal/bunny/api.go')
| -rw-r--r-- | internal/bunny/api.go | 170 |
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) +} |