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:
agent-company
2026-03-28 18:08:48 +00:00
parent 77c8e92e38
commit e6ce6bc6c6
2 changed files with 249 additions and 20 deletions
+91 -20
View File
@@ -8,8 +8,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"math"
"net/http" "net/http"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -27,6 +30,11 @@ type Client struct {
maxConcurrent int maxConcurrent int
// cacheTTL controls how long cache entries remain valid. // cacheTTL controls how long cache entries remain valid.
cacheTTL time.Duration cacheTTL time.Duration
// maxRetries is the maximum number of retries for rate-limited requests.
maxRetries int
// baseRetryDelay is the initial backoff delay before the first retry.
baseRetryDelay time.Duration
} }
type cacheEntry struct { type cacheEntry struct {
@@ -129,39 +137,102 @@ func NewClient(baseURL string) *Client {
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
cache: make(map[string]*cacheEntry), cache: make(map[string]*cacheEntry),
maxConcurrent: 5, maxConcurrent: 5,
cacheTTL: 30 * time.Second, cacheTTL: 30 * time.Second,
maxRetries: 3,
baseRetryDelay: 1 * time.Second,
} }
} }
// doRequest performs an authenticated HTTP request to the Gitea API. // doRequest performs an authenticated HTTP request to the Gitea API.
// It automatically retries on HTTP 429 (rate limit) responses with
// exponential backoff, respecting the Retry-After header when present.
func (c *Client) doRequest(ctx context.Context, token, method, path string, body io.Reader) (*http.Response, error) { func (c *Client) doRequest(ctx context.Context, token, method, path string, body io.Reader) (*http.Response, error) {
url := c.baseURL + "/api/v1" + path url := c.baseURL + "/api/v1" + path
req, err := http.NewRequestWithContext(ctx, method, url, body) // Read the body once so we can replay it on retries.
if err != nil { var bodyBytes []byte
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Accept", "application/json")
if body != nil { if body != nil {
req.Header.Set("Content-Type", "application/json") var err error
bodyBytes, err = io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("reading request body: %w", err)
}
} }
resp, err := c.httpClient.Do(req) var lastErr error
if err != nil { for attempt := 0; attempt <= c.maxRetries; attempt++ {
return nil, fmt.Errorf("executing request: %w", err) // Recreate the body reader for each attempt.
var reqBody io.Reader
if bodyBytes != nil {
reqBody = strings.NewReader(string(bodyBytes))
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Accept", "application/json")
if bodyBytes != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
// Not rate-limited: handle normally.
if resp.StatusCode != http.StatusTooManyRequests {
if resp.StatusCode >= 400 {
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
}
return resp, nil
}
// Rate-limited (429): close body and compute retry delay.
resp.Body.Close()
if attempt == c.maxRetries {
lastErr = fmt.Errorf("API rate limit exceeded after %d retries (429)", c.maxRetries)
break
}
delay := c.retryDelay(resp, attempt)
slog.Warn("rate limited by Gitea API, retrying",
"attempt", attempt+1,
"max_retries", c.maxRetries,
"delay", delay,
"path", path,
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
// Continue to next attempt.
}
} }
if resp.StatusCode >= 400 { return nil, lastErr
defer resp.Body.Close() }
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
}
return resp, nil // retryDelay computes the delay before the next retry attempt. It uses the
// Retry-After header value (in seconds) if present, otherwise falls back to
// exponential backoff: baseRetryDelay * 2^attempt.
func (c *Client) retryDelay(resp *http.Response, attempt int) time.Duration {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
}
// Exponential backoff: 1s, 2s, 4s, ...
return c.baseRetryDelay * time.Duration(math.Pow(2, float64(attempt)))
} }
// getFromCache returns cached data if still valid. // getFromCache returns cached data if still valid.
+158
View File
@@ -1087,3 +1087,161 @@ func TestListAllPullRequests_Pagination(t *testing.T) {
t.Error("page 2: HasMore should be false") 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)
}
}
}