Skip to content

Commit 004327c

Browse files
somanshreddyclaude
andcommitted
client: harden retry transport
- Cap drainAndClose to 4KB (prevents blocking on large error bodies) - Clamp Retry-After to MaxDelay (prevents 24h+ blocks) - Rename isRetryableStatus → isRetryableServerError, remove dead 429 case - Fix WithRetry zero-value bug: merge partial config instead of replacing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f164bf9 commit 004327c

2 files changed

Lines changed: 28 additions & 7 deletions

File tree

internal/client/client.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,20 @@ func WithHTTPClient(hc *http.Client) Option {
3535
return func(c *Client) { c.httpClient = hc }
3636
}
3737

38-
// WithRetry overrides the default retry configuration.
38+
// WithRetry merges the given config into the default retry configuration.
39+
// MaxRetries is always applied (0 is valid — means no retries).
40+
// BaseDelay and MaxDelay are only applied if positive, so
41+
// WithRetry(RetryConfig{MaxRetries: 1}) keeps the default delays.
3942
func WithRetry(config RetryConfig) Option {
40-
return func(c *Client) { c.retry = config }
43+
return func(c *Client) {
44+
c.retry.MaxRetries = config.MaxRetries
45+
if config.BaseDelay > 0 {
46+
c.retry.BaseDelay = config.BaseDelay
47+
}
48+
if config.MaxDelay > 0 {
49+
c.retry.MaxDelay = config.MaxDelay
50+
}
51+
}
4152
}
4253

4354
// WithNoRetry disables retries entirely.

internal/client/retry.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
6464
delay := backoffDelay(attempt, t.config)
6565
if resp != nil {
6666
if retryAfter := parseRetryAfter(resp.Header.Get("Retry-After")); retryAfter > delay {
67+
// Clamp Retry-After to MaxDelay — a server sending Retry-After: 86400
68+
// shouldn't block the CLI for 24 hours.
69+
if t.config.MaxDelay > 0 && retryAfter > t.config.MaxDelay {
70+
retryAfter = t.config.MaxDelay
71+
}
6772
delay = retryAfter
6873
}
6974
drainAndClose(resp.Body)
@@ -97,13 +102,16 @@ func shouldRetry(req *http.Request, resp *http.Response, err error) bool {
97102
if resp.StatusCode == http.StatusTooManyRequests {
98103
return true
99104
}
100-
return isRetryableStatus(resp.StatusCode) && isIdempotent(req.Method)
105+
// 5xx server errors — only retry for idempotent methods.
106+
return isRetryableServerError(resp.StatusCode) && isIdempotent(req.Method)
101107
}
102108

103-
func isRetryableStatus(code int) bool {
109+
// isRetryableServerError returns true for server-side errors worth retrying.
110+
// 429 (rate limited) is handled separately in shouldRetry — it's always retried
111+
// regardless of method because the server explicitly rejected without processing.
112+
func isRetryableServerError(code int) bool {
104113
switch code {
105-
case http.StatusTooManyRequests,
106-
http.StatusInternalServerError,
114+
case http.StatusInternalServerError,
107115
http.StatusBadGateway,
108116
http.StatusServiceUnavailable,
109117
http.StatusGatewayTimeout:
@@ -209,6 +217,8 @@ func drainAndClose(body io.ReadCloser) {
209217
if body == nil {
210218
return
211219
}
212-
_, _ = io.Copy(io.Discard, body)
220+
// Cap the drain to 4KB — response bodies on 429/5xx should be tiny.
221+
// A misbehaving server sending a large body won't block the retry loop.
222+
_, _ = io.Copy(io.Discard, io.LimitReader(body, 4096))
213223
_ = body.Close()
214224
}

0 commit comments

Comments
 (0)