feat: add rate-limit retry with exponential backoff in Gitea API client
Add automatic retry logic to doRequest for HTTP 429 responses. Uses Retry-After header when present, otherwise exponential backoff (1s, 2s, 4s). Respects context cancellation during waits. Defaults to 3 max retries with 1s base delay. Includes 7 new tests covering retry success, exhaustion, Retry-After header, context cancellation, non-429 errors, and backoff calculation. Closes leeworks-agents/gitea-mobile#132 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1087,3 +1087,161 @@ func TestListAllPullRequests_Pagination(t *testing.T) {
|
||||
t.Error("page 2: HasMore should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_RateLimitRetry(t *testing.T) {
|
||||
attempts := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts <= 2 {
|
||||
w.Header().Set("Retry-After", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `[{"username":"test-org"}]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
c.maxRetries = 3
|
||||
c.baseRetryDelay = 1 * time.Millisecond // Fast for tests.
|
||||
|
||||
resp, err := c.doRequest(context.Background(), "test-token", "GET", "/user/orgs", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after retries, got: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if attempts != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_RateLimitExhausted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
c.maxRetries = 2
|
||||
c.baseRetryDelay = 1 * time.Millisecond
|
||||
|
||||
_, err := c.doRequest(context.Background(), "test-token", "GET", "/user/orgs", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after exhausting retries")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limit exceeded") {
|
||||
t.Errorf("expected rate limit error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_RateLimitWithRetryAfterHeader(t *testing.T) {
|
||||
attempts := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `[]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
c.maxRetries = 3
|
||||
c.baseRetryDelay = 1 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
resp, err := c.doRequest(context.Background(), "test-token", "GET", "/user/orgs", nil)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Retry-After: 1 means 1 second delay.
|
||||
if elapsed < 900*time.Millisecond {
|
||||
t.Errorf("expected at least ~1s delay from Retry-After header, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_RateLimitCancelledContext(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
c.maxRetries = 3
|
||||
c.baseRetryDelay = 1 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.doRequest(ctx, "test-token", "GET", "/user/orgs", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_NonRateLimitErrorNotRetried(t *testing.T) {
|
||||
attempts := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprint(w, `{"message":"forbidden"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
c.maxRetries = 3
|
||||
c.baseRetryDelay = 1 * time.Millisecond
|
||||
|
||||
_, err := c.doRequest(context.Background(), "test-token", "GET", "/user/orgs", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403")
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("expected only 1 attempt for non-429 error, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelay_WithRetryAfterHeader(t *testing.T) {
|
||||
c := NewClient("https://example.com")
|
||||
c.baseRetryDelay = 1 * time.Second
|
||||
|
||||
resp := &http.Response{Header: http.Header{}}
|
||||
resp.Header.Set("Retry-After", "5")
|
||||
|
||||
delay := c.retryDelay(resp, 0)
|
||||
if delay != 5*time.Second {
|
||||
t.Errorf("expected 5s from Retry-After, got %v", delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelay_ExponentialBackoff(t *testing.T) {
|
||||
c := NewClient("https://example.com")
|
||||
c.baseRetryDelay = 1 * time.Second
|
||||
|
||||
resp := &http.Response{Header: http.Header{}}
|
||||
|
||||
tests := []struct {
|
||||
attempt int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 1 * time.Second},
|
||||
{1, 2 * time.Second},
|
||||
{2, 4 * time.Second},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
delay := c.retryDelay(resp, tt.attempt)
|
||||
if delay != tt.want {
|
||||
t.Errorf("attempt %d: got %v, want %v", tt.attempt, delay, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user