diff --git a/internal/gitea/client.go b/internal/gitea/client.go index 40e6e89..11fc61c 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -8,8 +8,11 @@ import ( "encoding/json" "fmt" "io" + "log/slog" + "math" "net/http" "sort" + "strconv" "strings" "sync" "time" @@ -27,6 +30,11 @@ type Client struct { maxConcurrent int // cacheTTL controls how long cache entries remain valid. 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 { @@ -129,39 +137,102 @@ func NewClient(baseURL string) *Client { httpClient: &http.Client{ Timeout: 30 * time.Second, }, - cache: make(map[string]*cacheEntry), - maxConcurrent: 5, - cacheTTL: 30 * time.Second, + cache: make(map[string]*cacheEntry), + maxConcurrent: 5, + cacheTTL: 30 * time.Second, + maxRetries: 3, + baseRetryDelay: 1 * time.Second, } } // 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) { url := c.baseURL + "/api/v1" + path - req, err := http.NewRequestWithContext(ctx, method, url, body) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - - req.Header.Set("Authorization", "token "+token) - req.Header.Set("Accept", "application/json") + // Read the body once so we can replay it on retries. + var bodyBytes []byte 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) - if err != nil { - return nil, fmt.Errorf("executing request: %w", err) + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + // 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 { - defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) - } + return nil, lastErr +} - 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. diff --git a/internal/gitea/client_test.go b/internal/gitea/client_test.go index 7e357ce..8f8e91f 100644 --- a/internal/gitea/client_test.go +++ b/internal/gitea/client_test.go @@ -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) + } + } +}