-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.go
More file actions
213 lines (185 loc) · 5.38 KB
/
Copy pathclient.go
File metadata and controls
213 lines (185 loc) · 5.38 KB
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
package craftedsignal
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"strconv"
"time"
)
// BackoffFunc calculates the wait duration before retry attempt n (0-indexed).
type BackoffFunc func(attempt int) time.Duration
// ExponentialBackoff waits 2^n seconds between retries (1s, 2s, 4s, …).
var ExponentialBackoff BackoffFunc = func(attempt int) time.Duration {
return time.Duration(math.Pow(2, float64(attempt))) * time.Second
}
// NoRetry disables retries.
var NoRetry BackoffFunc = func(_ int) time.Duration { return 0 }
type transport struct {
token Token
baseURL string
httpClient *http.Client
maxRetries int
backoff BackoffFunc
logger *slog.Logger
verbose bool
pollInterval time.Duration
userAgent string
}
type apiEnvelope struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
Error *apiErrBody `json:"error,omitempty"`
}
type apiErrBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// do executes an HTTP request with retry logic.
// The caller is responsible for passing resp to decode(), which closes the body.
func (t *transport) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
var rawBody []byte
if body != nil {
var err error
rawBody, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("craftedsignal: marshal request: %w", err)
}
}
var lastErr error
for attempt := 0; attempt <= t.maxRetries; attempt++ {
if attempt > 0 {
wait := t.backoff(attempt - 1)
t.logDebug("retrying request",
slog.String("method", method),
slog.String("path", path),
slog.Int("attempt", attempt),
slog.Duration("wait", wait),
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
var reqBody io.Reader
if rawBody != nil {
reqBody = bytes.NewReader(rawBody)
}
req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("craftedsignal: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+t.token.value())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if t.userAgent != "" {
req.Header.Set("User-Agent", t.userAgent)
}
t.logDebug("request", slog.String("method", method), slog.String("path", path))
resp, err := t.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("craftedsignal: %w", err)
continue
}
t.logDebug("response",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
)
// Retry on 429 with Retry-After support
if resp.StatusCode == http.StatusTooManyRequests && attempt < t.maxRetries {
wait := parseRetryAfter(resp.Header.Get("Retry-After"), t.backoff(attempt))
_ = resp.Body.Close()
t.logger.Warn("rate limited",
slog.Int("attempt", attempt+1),
slog.Duration("retry_after", wait),
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
continue
}
// Retry on 5xx (except last attempt)
if resp.StatusCode >= 500 && attempt < t.maxRetries {
_ = resp.Body.Close()
lastErr = &Error{StatusCode: resp.StatusCode}
continue
}
return resp, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("craftedsignal: request failed after %d attempts", t.maxRetries+1)
}
// decode reads the API envelope from resp and unmarshals Data into out.
// It always closes resp.Body. out may be nil to discard the data field.
func (t *transport) decode(resp *http.Response, out any) error {
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return fmt.Errorf("craftedsignal: read response: %w", err)
}
switch resp.StatusCode {
case http.StatusUnauthorized:
return ErrUnauthorized
case http.StatusForbidden:
return ErrForbidden
case http.StatusNotFound:
return ErrNotFound
}
if resp.StatusCode >= 400 {
var env apiEnvelope
if json.Unmarshal(body, &env) == nil && env.Error != nil {
return &Error{Code: env.Error.Code, Message: env.Error.Message, StatusCode: resp.StatusCode}
}
return &Error{Code: "unexpected_error", Message: string(body), StatusCode: resp.StatusCode}
}
if out == nil {
return nil
}
var env apiEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return fmt.Errorf("craftedsignal: decode envelope: %w", err)
}
if err := json.Unmarshal(env.Data, out); err != nil {
return fmt.Errorf("craftedsignal: decode data: %w", err)
}
return nil
}
func (t *transport) logDebug(msg string, args ...any) {
if !t.verbose {
return
}
t.logger.Debug(msg, args...)
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func newInsecureTransport(base *http.Transport) *http.Transport {
clone := base.Clone()
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402 -- intentional, WithInsecure() is for dev/self-signed only
return clone
}
func parseRetryAfter(header string, fallback time.Duration) time.Duration {
if header == "" {
return fallback
}
if secs, err := strconv.Atoi(header); err == nil {
return time.Duration(secs) * time.Second
}
if ts, err := http.ParseTime(header); err == nil {
if d := time.Until(ts); d > 0 {
return d
}
}
return fallback
}