package main import ( "context" "fmt" "io" "log" "net/http" "strconv" "time" "golang.org/x/time/rate" ) const baseURL = "https://api.solidarity.tech/v1" type Client struct { key string dryRun bool http *http.Client limiter *rate.Limiter } func NewClient(key string, dryRun bool) *Client { return &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), } } // 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 } 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]) + "..." } return string(b) }