Skip to content

Commit c9cb733

Browse files
committed
fix: cap honored Retry-After waits at 60 seconds
Sleeps between retry attempts sit outside the per-attempt timeout, so honoring an arbitrary Retry-After could park a call for hours. A mandated wait beyond 60s now surfaces the 429 immediately, with the full wait still available on the error's RateLimit.RetryAfter.
1 parent 289cb74 commit c9cb733

4 files changed

Lines changed: 113 additions & 3 deletions

File tree

client.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,10 @@ func (c *Client) request(ctx context.Context, method, path string, query url.Val
129129
}
130130

131131
// send builds and performs one HTTP call, retrying transient failures
132-
// with exponential backoff and jitter, honoring Retry-After. What
132+
// with exponential backoff and jitter, honoring Retry-After up to a
133+
// 60-second cap: a longer mandated wait surfaces the response
134+
// immediately instead of parking the goroutine (the full wait still
135+
// reaches the caller via the error's RateLimit.RetryAfter). What
133136
// counts as transient depends on the method: see
134137
// transport.RetryableStatus. Callers own the response body.
135138
func (c *Client) send(ctx context.Context, method, path string, query url.Values, body any, creds Credentials, extra http.Header) (*http.Response, error) {
@@ -153,7 +156,8 @@ func (c *Client) send(ctx context.Context, method, path string, query url.Values
153156
if !transport.IdempotentMethod(method) || attempt >= c.maxRetries || ctx.Err() != nil {
154157
return nil, err
155158
}
156-
} else if !transport.RetryableStatus(method, resp.StatusCode) || attempt >= c.maxRetries {
159+
} else if !transport.RetryableStatus(method, resp.StatusCode) || attempt >= c.maxRetries ||
160+
transport.RetryAfterExceedsCap(resp.Header.Get("Retry-After")) {
157161
return resp, nil
158162
}
159163
var retryAfter string

internal/transport/transport.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ const (
1919
RetryBaseDelay = 500 * time.Millisecond
2020
// retryMaxDelay caps a single computed backoff.
2121
retryMaxDelay = 8 * time.Second
22+
// maxRetryAfter is the longest server-mandated Retry-After the
23+
// retry loop will honor. Sleeps between attempts fall outside the
24+
// per-attempt timeout, so honoring an arbitrary Retry-After would
25+
// park the caller for unbounded wall-clock time per call. Beyond
26+
// this cap the response surfaces immediately instead; the full
27+
// mandated wait still reaches the caller through the error's
28+
// RateLimit.RetryAfter.
29+
maxRetryAfter = 60 * time.Second
2230
)
2331

2432
// IdempotentMethod reports whether an HTTP method is safe to replay
@@ -63,6 +71,14 @@ func RetryDelay(base time.Duration, attempt int, retryAfter string) time.Duratio
6371
return d/2 + rand.N(d/2+1) //nolint:gosec // math/rand jitter; no security material
6472
}
6573

74+
// RetryAfterExceedsCap reports whether a parsable Retry-After header
75+
// mandates a wait longer than maxRetryAfter. Callers give up on
76+
// retrying and surface the response instead when it does.
77+
func RetryAfterExceedsCap(v string) bool {
78+
d, ok := ParseRetryAfter(v, time.Now())
79+
return ok && d > maxRetryAfter
80+
}
81+
6682
// ParseRetryAfter reads a Retry-After header value: delay seconds or an
6783
// HTTP date (RFC 9110 §10.2.3). ok is false when absent or malformed.
6884
func ParseRetryAfter(v string, now time.Time) (_ time.Duration, ok bool) {

internal/transport/transport_test.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,34 @@
11
package transport
22

3-
import "testing"
3+
import (
4+
"net/http"
5+
"testing"
6+
"time"
7+
)
8+
9+
// Only a parsable Retry-After longer than maxRetryAfter trips the cap;
10+
// exactly 60s is still honored, and absent or malformed headers fall
11+
// through to computed backoff.
12+
func TestRetryAfterExceedsCap(t *testing.T) {
13+
tests := []struct {
14+
v string
15+
want bool
16+
}{
17+
{"", false},
18+
{"garbage", false},
19+
{"0", false},
20+
{"60", false},
21+
{"61", true},
22+
{"3600", true},
23+
{time.Now().Add(2 * time.Hour).UTC().Format(http.TimeFormat), true},
24+
{time.Now().Add(5 * time.Second).UTC().Format(http.TimeFormat), false},
25+
}
26+
for _, tt := range tests {
27+
if got := RetryAfterExceedsCap(tt.v); got != tt.want {
28+
t.Errorf("RetryAfterExceedsCap(%q) = %v, want %v", tt.v, got, tt.want)
29+
}
30+
}
31+
}
432

533
// The filename is server-supplied and consumers hand it straight to
634
// os.Create, so path-shaped suggestions must never survive: relative

retry_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,68 @@ func TestRetryHonorsRetryAfter(t *testing.T) {
129129
}
130130
}
131131

132+
// A Retry-After within the 60s cap stays authoritative: the retry
133+
// waits out the mandated second before the next attempt.
134+
func TestRetryAfterWithinCapHonored(t *testing.T) {
135+
var attempts atomic.Int32
136+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
137+
if attempts.Add(1) == 1 {
138+
w.Header().Set("Retry-After", "1")
139+
w.WriteHeader(http.StatusTooManyRequests)
140+
w.Write([]byte(`{"error":"slow down","code":"rate_limit_exceeded"}`))
141+
return
142+
}
143+
w.Write([]byte(`{"user":{"id":"1"}}`))
144+
}))
145+
defer srv.Close()
146+
147+
c := NewClient(option.WithBaseURL(srv.URL))
148+
fastRetries(c)
149+
start := time.Now()
150+
if _, err := c.Me(context.Background()); err != nil {
151+
t.Fatal(err)
152+
}
153+
if elapsed := time.Since(start); elapsed < time.Second {
154+
t.Fatalf("retry took %v, the mandated 1s wait was not honored", elapsed)
155+
}
156+
if attempts.Load() != 2 {
157+
t.Fatalf("attempts = %d, want 2", attempts.Load())
158+
}
159+
}
160+
161+
// A Retry-After beyond the cap would park the goroutine for the whole
162+
// mandated wait (sleeps between attempts sit outside the per-attempt
163+
// timeout), so the 429 must surface immediately, carrying the full
164+
// wait on the error for the caller to schedule around.
165+
func TestRetryAfterBeyondCapSurfacesImmediately(t *testing.T) {
166+
var attempts atomic.Int32
167+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
168+
attempts.Add(1)
169+
w.Header().Set("Retry-After", "3600")
170+
w.WriteHeader(http.StatusTooManyRequests)
171+
w.Write([]byte(`{"error":"slow down","code":"rate_limit_exceeded"}`))
172+
}))
173+
defer srv.Close()
174+
175+
c := NewClient(option.WithBaseURL(srv.URL))
176+
fastRetries(c)
177+
start := time.Now()
178+
_, err := c.Me(context.Background())
179+
var apiErr *Error
180+
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusTooManyRequests {
181+
t.Fatalf("err = %v, want *Error with 429", err)
182+
}
183+
if apiErr.RateLimit.RetryAfter != 3600*time.Second {
184+
t.Fatalf("RetryAfter = %v, want 1h (the full mandated wait)", apiErr.RateLimit.RetryAfter)
185+
}
186+
if elapsed := time.Since(start); elapsed > 2*time.Second {
187+
t.Fatalf("took %v, the hour-long Retry-After was slept on", elapsed)
188+
}
189+
if attempts.Load() != 1 {
190+
t.Fatalf("attempts = %d, want 1 (a wait past the cap must not retry)", attempts.Load())
191+
}
192+
}
193+
132194
// failOnceTransport drops the first request at the transport level to
133195
// simulate a connection error, then delegates.
134196
type failOnceTransport struct {

0 commit comments

Comments
 (0)