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) }