1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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)
}
|