diff --git a/client.go b/client.go index 1c3641d..23cb5e0 100644 --- a/client.go +++ b/client.go @@ -29,59 +29,65 @@ func NewClient(key string, dryRun bool) *Client { key: key, dryRun: dryRun, http: &http.Client{Timeout: 30 * time.Second}, - // 60 requests per 30 seconds: 2/sec sustained, burst of 60. - limiter: rate.NewLimiter(2, 60), + // limit to 2 requests per second, no bursts to avoid 429s + limiter: rate.NewLimiter(2, 1), } } // do applies the rate limit, retries once on 429, and returns the raw body. -func (c *Client) do(ctx context.Context, method, path string, body io.Reader) ([]byte, error) { - if err := c.limiter.Wait(ctx); err != nil { - return nil, err - } +// rebuilds the body on every attempt +func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, error) { + for attempt := 0; ; attempt++ { + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + var r io.Reader + if body != nil { + r = bytes.NewReader(body) // fresh reader each attempt + } + req, err := http.NewRequestWithContext(ctx, method, baseURL+path, r) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.key) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } - req, err := http.NewRequestWithContext(ctx, method, baseURL+path, body) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+c.key) - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } - resp, err := c.http.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusTooManyRequests { - wait := 30 * time.Second - if s := resp.Header.Get("Retry-After"); s != "" { - if secs, err := strconv.Atoi(s); err == nil { - wait = time.Duration(secs) * time.Second + if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 { + resp.Body.Close() + wait := 30 * time.Second + if s := resp.Header.Get("Retry-After"); s != "" { + if secs, err := strconv.Atoi(s); err == nil { + wait = time.Duration(secs) * time.Second + } + } + log.Printf("rate limited; waiting %s", wait) + select { + case <-time.After(wait): + continue + case <-ctx.Done(): + return nil, ctx.Err() } } - log.Printf("rate limited; waiting %s", wait) - select { - case <-time.After(wait): - case <-ctx.Done(): - return nil, ctx.Err() + + raw, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err } - return c.do(ctx, method, path, body) + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("%s %s: %d: %s", method, path, resp.StatusCode, truncate(raw)) + } + return raw, nil } - - raw, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return nil, fmt.Errorf("%s %s: %d: %s", method, path, resp.StatusCode, truncate(raw)) - } - return raw, nil } - func truncate(b []byte) string { if len(b) > 300 { return string(b[:300]) + "..."