package main import ( "bytes" "context" "fmt" "io" "net/http" "strconv" "time" ) const maxRetries = 3 var client = &http.Client{ Timeout: 30 * time.Second, } func Do(ctx context.Context, req *http.Request) (*http.Response, error) { return client.Do(req) } func DoWithRateLimitReset(ctx context.Context, req *http.Request) (*http.Response, error) { var getBody func() (io.ReadCloser, error) if req.Body != nil { b, err := io.ReadAll(req.Body) if err != nil { return nil, err } _ = req.Body.Close() getBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(b)), nil } req.GetBody = getBody req.Body, _ = getBody() } const safety = 250 * time.Millisecond // small buffer to avoid edge timing for attempt := 0; attempt <= maxRetries; attempt++ { r := req.Clone(ctx) if getBody != nil { r.Body, _ = getBody() } resp, err := client.Do(r) if err != nil { return nil, err } if resp.StatusCode != http.StatusTooManyRequests { return resp, nil } resetHeader := resp.Header.Get("X-RateLimit-Reset") _ = resp.Body.Close() if attempt == maxRetries { return nil, fmt.Errorf("rate limited (429) after %d retries; last reset header=%q", maxRetries, resetHeader) } wait, err := waitUntilUnixReset(resetHeader, safety) if err != nil { // If the header is missing/invalid, fall back to a short backoff wait = 2 * time.Second } select { case <-time.After(wait): case <-ctx.Done(): return nil, ctx.Err() } } return nil, fmt.Errorf("unreachable") } func waitUntilUnixReset(h string, safety time.Duration) (time.Duration, error) { secs, err := strconv.ParseInt(h, 10, 64) if err != nil { return 0, err } resetAt := time.Unix(secs, 0) wait := time.Until(resetAt) + safety if wait < 0 { wait = 0 } return wait, nil }