diff --git a/client.go b/client.go index 23cb5e0..1c3641d 100644 --- a/client.go +++ b/client.go @@ -29,65 +29,59 @@ func NewClient(key string, dryRun bool) *Client { key: key, dryRun: dryRun, http: &http.Client{Timeout: 30 * time.Second}, - // limit to 2 requests per second, no bursts to avoid 429s - limiter: rate.NewLimiter(2, 1), + // 60 requests per 30 seconds: 2/sec sustained, burst of 60. + limiter: rate.NewLimiter(2, 60), } } // do applies the rate limit, retries once on 429, and returns the raw body. -// 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") - } - - resp, err := c.http.Do(req) - if err != nil { - return nil, err - } - - 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() - } - } - - raw, err := io.ReadAll(resp.Body) - resp.Body.Close() - 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 (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 } + + 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 + } + 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 + } + } + log.Printf("rate limited; waiting %s", wait) + select { + case <-time.After(wait): + case <-ctx.Done(): + return nil, ctx.Err() + } + return c.do(ctx, method, path, body) + } + + 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]) + "..."