Skip to content

Commit a3e1bea

Browse files
mycollablabclaude
andcommitted
Add 429 rate-limit retry with Retry-After backoff
On a 429 response the client now reads the Retry-After header (seconds), sleeps that long, and retries up to 3 times. Falls back to 5 seconds if the header is absent or unparseable. Prints a message to stderr so the user knows why the command is pausing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1532cc8 commit a3e1bea

1 file changed

Lines changed: 33 additions & 10 deletions

File tree

internal/client/client.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,53 @@ import (
77
"net/http"
88
urlpkg "net/url"
99
"os"
10+
"strconv"
1011
"strings"
12+
"time"
1113

1214
"github.com/Cloverhound/webex-cli/internal/config"
1315
)
1416

1517
// ErrDryRun is returned when a write operation is intercepted by --dry-run mode.
1618
var ErrDryRun = errors.New("dry run: no changes made")
1719

18-
// Do executes an HTTP request. On a 401, it attempts to refresh the token and retry once.
20+
// Do executes an HTTP request.
21+
// On a 401 it refreshes the token and retries once.
22+
// On a 429 it reads Retry-After and retries up to 3 times.
1923
func Do(req *Request) ([]byte, int, error) {
20-
body, status, err := doOnce(req)
24+
body, status, headers, err := doOnce(req)
25+
2126
if status == 401 && config.TokenRefresher != nil {
2227
newToken, refreshErr := config.TokenRefresher()
2328
if refreshErr != nil {
2429
return body, status, err
2530
}
2631
config.SetToken(newToken)
27-
return doOnce(req)
32+
body, status, headers, err = doOnce(req)
33+
}
34+
35+
for retries := 0; status == 429 && retries < 3; retries++ {
36+
wait := retryAfterDuration(headers.Get("Retry-After"))
37+
fmt.Fprintf(os.Stderr, "Rate limited (429). Retrying in %s...\n", wait.Round(time.Second))
38+
time.Sleep(wait)
39+
body, status, headers, err = doOnce(req)
2840
}
41+
2942
return body, status, err
3043
}
3144

45+
// retryAfterDuration parses the Retry-After header value (seconds integer) and
46+
// returns the duration to wait. Defaults to 5 seconds if the header is absent
47+
// or unparseable.
48+
func retryAfterDuration(header string) time.Duration {
49+
if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs > 0 {
50+
return time.Duration(secs) * time.Second
51+
}
52+
return 5 * time.Second
53+
}
54+
3255
// doOnce executes a single HTTP request without retry.
33-
func doOnce(req *Request) ([]byte, int, error) {
56+
func doOnce(req *Request) ([]byte, int, http.Header, error) {
3457
// Build URL
3558
url := req.baseURL + req.path
3659
for k, v := range req.pathParams {
@@ -58,7 +81,7 @@ func doOnce(req *Request) ([]byte, int, error) {
5881

5982
httpReq, err := http.NewRequest(req.method, url, bodyReader)
6083
if err != nil {
61-
return nil, 0, fmt.Errorf("building request: %w", err)
84+
return nil, 0, nil, fmt.Errorf("building request: %w", err)
6285
}
6386

6487
// Auth
@@ -99,29 +122,29 @@ func doOnce(req *Request) ([]byte, int, error) {
99122
if req.bodyRaw != "" {
100123
fmt.Fprintf(os.Stderr, "[DRY RUN] Body: %s\n", req.bodyRaw)
101124
}
102-
return nil, 0, ErrDryRun
125+
return nil, 0, nil, ErrDryRun
103126
}
104127

105128
resp, err := http.DefaultClient.Do(httpReq)
106129
if err != nil {
107-
return nil, 0, fmt.Errorf("executing request: %w", err)
130+
return nil, 0, nil, fmt.Errorf("executing request: %w", err)
108131
}
109132
defer resp.Body.Close()
110133

111134
body, err := io.ReadAll(resp.Body)
112135
if err != nil {
113-
return nil, resp.StatusCode, fmt.Errorf("reading response: %w", err)
136+
return nil, resp.StatusCode, resp.Header, fmt.Errorf("reading response: %w", err)
114137
}
115138

116139
if config.Debug() {
117140
fmt.Fprintf(os.Stderr, "DEBUG: Response %d (%d bytes)\n", resp.StatusCode, len(body))
118141
}
119142

120143
if resp.StatusCode >= 400 {
121-
return body, resp.StatusCode, fmt.Errorf("API error %d: %s", resp.StatusCode, truncate(string(body), 500))
144+
return body, resp.StatusCode, resp.Header, fmt.Errorf("API error %d: %s", resp.StatusCode, truncate(string(body), 500))
122145
}
123146

124-
return body, resp.StatusCode, nil
147+
return body, resp.StatusCode, resp.Header, nil
125148
}
126149

127150
func truncate(s string, n int) string {

0 commit comments

Comments
 (0)