Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 329d68e4b4 | |||
| 3995fa3136 | |||
| d468ea6022 |
+8
-25
@@ -21,10 +21,6 @@ const (
|
|||||||
|
|
||||||
// maxResponseBytes limits successful response body reads to 10 MiB.
|
// maxResponseBytes limits successful response body reads to 10 MiB.
|
||||||
maxResponseBytes = 10 * 1024 * 1024
|
maxResponseBytes = 10 * 1024 * 1024
|
||||||
|
|
||||||
// maxRetryAttempts is the number of times doRequest will attempt a request.
|
|
||||||
// The retry backoff slice must have length maxRetryAttempts-1.
|
|
||||||
maxRetryAttempts = 3
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// APIError represents an HTTP error response from the GitHub API.
|
// APIError represents an HTTP error response from the GitHub API.
|
||||||
@@ -182,33 +178,24 @@ func (c *Client) SetHTTPClient(hc *http.Client) {
|
|||||||
|
|
||||||
// SetRetryBackoff configures the retry backoff durations for testing.
|
// SetRetryBackoff configures the retry backoff durations for testing.
|
||||||
// It must be called before any goroutines issue requests.
|
// It must be called before any goroutines issue requests.
|
||||||
// The slice must have exactly maxRetryAttempts-1 entries (one delay per retry gap).
|
|
||||||
// In production the default {1s, 2s} applies.
|
// In production the default {1s, 2s} applies.
|
||||||
func (c *Client) SetRetryBackoff(d []time.Duration) error {
|
func (c *Client) SetRetryBackoff(d []time.Duration) {
|
||||||
if len(d) != maxRetryAttempts-1 {
|
|
||||||
return fmt.Errorf("github: backoff length %d does not match maxRetryAttempts-1 (%d)", len(d), maxRetryAttempts-1)
|
|
||||||
}
|
|
||||||
c.retryBackoff = d
|
c.retryBackoff = d
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
||||||
// It respects the Retry-After header when present (capped at maxRetryAfter).
|
// It respects the Retry-After header when present (capped at maxRetryAfter).
|
||||||
// Transport errors (network failures, context cancellation) are not retried.
|
// Transport errors (network failures, context cancellation) are not retried.
|
||||||
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
||||||
|
const maxAttempts = 3
|
||||||
const maxRetryAfter = 120 * time.Second
|
const maxRetryAfter = 120 * time.Second
|
||||||
|
|
||||||
// backoff holds per-attempt delays: backoff[i] is the delay before attempt i+1.
|
|
||||||
// Length must be maxRetryAttempts-1 (one entry per retry gap).
|
|
||||||
// SetRetryBackoff validates at configuration time; the default is always valid.
|
|
||||||
defaultBackoff := []time.Duration{1 * time.Second, 2 * time.Second}
|
|
||||||
var backoff []time.Duration
|
var backoff []time.Duration
|
||||||
if c.retryBackoff != nil && len(c.retryBackoff) == maxRetryAttempts-1 {
|
if c.retryBackoff != nil {
|
||||||
backoff = make([]time.Duration, len(c.retryBackoff))
|
backoff = make([]time.Duration, len(c.retryBackoff))
|
||||||
copy(backoff, c.retryBackoff)
|
copy(backoff, c.retryBackoff)
|
||||||
} else {
|
} else {
|
||||||
backoff = make([]time.Duration, len(defaultBackoff))
|
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
|
||||||
copy(backoff, defaultBackoff)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxErrorBodyBytes limits how much of an error response body is stored.
|
// maxErrorBodyBytes limits how much of an error response body is stored.
|
||||||
@@ -228,7 +215,7 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < maxRetryAttempts; attempt++ {
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
var delay time.Duration
|
var delay time.Duration
|
||||||
if attempt-1 < len(backoff) {
|
if attempt-1 < len(backoff) {
|
||||||
@@ -268,10 +255,6 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
return nil, fmt.Errorf("do request: %w", err)
|
return nil, fmt.Errorf("do request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture response metadata before handleResponse takes body ownership.
|
|
||||||
respStatus := resp.StatusCode
|
|
||||||
retryAfterHeader := resp.Header.Get("Retry-After")
|
|
||||||
|
|
||||||
body, done, err := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
body, done, err := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
||||||
if done {
|
if done {
|
||||||
return body, err
|
return body, err
|
||||||
@@ -279,10 +262,10 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
// Retry on 429 rate limit
|
// Retry on 429 rate limit
|
||||||
if respStatus == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
if resp.StatusCode == http.StatusTooManyRequests && attempt < maxAttempts-1 {
|
||||||
// Check for Retry-After header and override backoff if present.
|
// Check for Retry-After header and override backoff if present.
|
||||||
// Supports both integer seconds (common) and HTTP-date format (RFC 7231).
|
// Supports both integer seconds (common) and HTTP-date format (RFC 7231).
|
||||||
if ra := retryAfterHeader; ra != "" {
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||||
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
|
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
|
||||||
delay := time.Duration(seconds) * time.Second
|
delay := time.Duration(seconds) * time.Second
|
||||||
if delay > maxRetryAfter {
|
if delay > maxRetryAfter {
|
||||||
@@ -326,7 +309,7 @@ func (c *Client) handleResponse(resp *http.Response, maxRespBytes int, maxErrByt
|
|||||||
return nil, true, fmt.Errorf("read response body: %w", err)
|
return nil, true, fmt.Errorf("read response body: %w", err)
|
||||||
}
|
}
|
||||||
if len(body) > maxRespBytes {
|
if len(body) > maxRespBytes {
|
||||||
return nil, true, fmt.Errorf("response body exceeded %d bytes", maxRespBytes)
|
return nil, true, fmt.Errorf("response body exceeded %d bytes (truncated)", maxRespBytes)
|
||||||
}
|
}
|
||||||
return body, true, nil
|
return body, true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-44
@@ -83,9 +83,7 @@ func TestDoRequest_429Retry(t *testing.T) {
|
|||||||
|
|
||||||
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
c.SetHTTPClient(srv.Client())
|
c.SetHTTPClient(srv.Client())
|
||||||
if err := c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond}); err != nil {
|
c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond})
|
||||||
t.Fatalf("SetRetryBackoff: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,9 +108,7 @@ func TestDoRequest_429ExhaustsRetries(t *testing.T) {
|
|||||||
|
|
||||||
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
c.SetHTTPClient(srv.Client())
|
c.SetHTTPClient(srv.Client())
|
||||||
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
||||||
t.Fatalf("SetRetryBackoff: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -222,9 +218,7 @@ func TestDoRequest_429RetryAfterHeader(t *testing.T) {
|
|||||||
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
c.SetHTTPClient(srv.Client())
|
c.SetHTTPClient(srv.Client())
|
||||||
// Use short backoff; Retry-After should override
|
// Use short backoff; Retry-After should override
|
||||||
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
||||||
t.Fatalf("SetRetryBackoff: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
@@ -265,9 +259,7 @@ func TestDoRequest_RetryAfterDoesNotMutateBackoff(t *testing.T) {
|
|||||||
|
|
||||||
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
c.SetHTTPClient(srv.Client())
|
c.SetHTTPClient(srv.Client())
|
||||||
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
||||||
t.Fatalf("SetRetryBackoff: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,9 +297,7 @@ func TestDoRequest_429RetryAfterHTTPDate(t *testing.T) {
|
|||||||
|
|
||||||
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
c.SetHTTPClient(srv.Client())
|
c.SetHTTPClient(srv.Client())
|
||||||
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
||||||
t.Fatalf("SetRetryBackoff: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
@@ -348,9 +338,7 @@ func TestDoRequest_429RetryAfterHTTPDateInPast(t *testing.T) {
|
|||||||
|
|
||||||
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
||||||
c.SetHTTPClient(srv.Client())
|
c.SetHTTPClient(srv.Client())
|
||||||
if err := c.SetRetryBackoff([]time.Duration{5 * time.Second, 5 * time.Second}); err != nil {
|
c.SetRetryBackoff([]time.Duration{5 * time.Second, 5 * time.Second})
|
||||||
t.Fatalf("SetRetryBackoff: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
@@ -566,29 +554,3 @@ func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
|||||||
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestSetRetryBackoff_RejectsInvalidLength(t *testing.T) {
|
|
||||||
c := NewClient("token", "https://api.github.com")
|
|
||||||
|
|
||||||
// Too short
|
|
||||||
err := c.SetRetryBackoff([]time.Duration{1 * time.Second})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for backoff length 1")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "backoff length 1") {
|
|
||||||
t.Errorf("unexpected error message: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Too long
|
|
||||||
err = c.SetRetryBackoff([]time.Duration{1 * time.Second, 2 * time.Second, 3 * time.Second})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for backoff length 3")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Correct length succeeds
|
|
||||||
err = c.SetRetryBackoff([]time.Duration{1 * time.Second, 2 * time.Second})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error for valid backoff: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user