Skip to content

Commit 348cf93

Browse files
committed
feat: add HTTP 429 rate limit handling with retry support
- Automatically retry on HTTP 429 (Too Many Requests) errors - Respect Retry-After header when provided by the API - Fall back to exponential backoff if no Retry-After header - Add tests to verify rate limit handling works correctly - Prevents provider failures when hitting API rate limits
1 parent 51851bf commit 348cf93

2 files changed

Lines changed: 147 additions & 1 deletion

File tree

internal/client/client.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"strconv"
1112
"strings"
1213
"time"
1314

@@ -21,6 +22,20 @@ type Client struct {
2122
httpClient *http.Client
2223
}
2324

25+
// RateLimitError represents a 429 rate limit error with optional Retry-After information
26+
type RateLimitError struct {
27+
StatusCode int
28+
Message string
29+
RetryAfter string // Can be seconds or HTTP-date
30+
}
31+
32+
func (e *RateLimitError) Error() string {
33+
if e.RetryAfter != "" {
34+
return fmt.Sprintf("HTTP %d: %s (Retry-After: %s)", e.StatusCode, e.Message, e.RetryAfter)
35+
}
36+
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
37+
}
38+
2439
// NewClient creates a new Pocket-ID API client
2540
func NewClient(baseURL, apiToken string, skipTLSVerify bool, timeout int64) (*Client, error) {
2641
if baseURL == "" {
@@ -58,9 +73,24 @@ func (c *Client) doRequestWithContext(ctx context.Context, method, endpoint stri
5873
var lastErr error
5974

6075
for attempt := 0; attempt <= maxRetries; attempt++ {
76+
var backoff time.Duration
77+
6178
if attempt > 0 {
6279
// Exponential backoff: 1s, 2s, 4s
63-
backoff := time.Duration(1<<(attempt-1)) * time.Second
80+
backoff = time.Duration(1<<(attempt-1)) * time.Second
81+
82+
// Special handling for rate limit errors to respect Retry-After header
83+
if rateLimitErr, ok := lastErr.(*RateLimitError); ok && rateLimitErr.RetryAfter != "" {
84+
retryAfterSeconds := parseRetryAfter(rateLimitErr.RetryAfter)
85+
if retryAfterSeconds > 0 {
86+
backoff = time.Duration(retryAfterSeconds) * time.Second
87+
tflog.Info(ctx, "Rate limited, using Retry-After header", map[string]interface{}{
88+
"retry_after": rateLimitErr.RetryAfter,
89+
"backoff_seconds": retryAfterSeconds,
90+
})
91+
}
92+
}
93+
6494
tflog.Debug(ctx, "Retrying request after backoff", map[string]interface{}{
6595
"attempt": attempt,
6696
"backoff": backoff.String(),
@@ -120,6 +150,16 @@ func isRetryableError(err error) bool {
120150
return true
121151
}
122152

153+
// 429 Too Many Requests is retryable (rate limiting)
154+
if strings.Contains(errStr, "HTTP 429") {
155+
return true
156+
}
157+
158+
// Check if it's a RateLimitError
159+
if _, ok := err.(*RateLimitError); ok {
160+
return true
161+
}
162+
123163
return false
124164
}
125165

@@ -199,18 +239,56 @@ func (c *Client) doSingleRequest(ctx context.Context, method, endpoint string, b
199239
"status_code": resp.StatusCode,
200240
"raw_body": string(respBody),
201241
})
242+
// Handle rate limit errors with Retry-After header
243+
if resp.StatusCode == 429 {
244+
retryAfter := resp.Header.Get("Retry-After")
245+
return nil, &RateLimitError{
246+
StatusCode: resp.StatusCode,
247+
Message: string(respBody),
248+
RetryAfter: retryAfter,
249+
}
250+
}
202251
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
203252
}
204253
tflog.Error(ctx, "API Error", map[string]interface{}{
205254
"status_code": resp.StatusCode,
206255
"error": errResp.Error,
207256
})
257+
// Handle rate limit errors with Retry-After header
258+
if resp.StatusCode == 429 {
259+
retryAfter := resp.Header.Get("Retry-After")
260+
return nil, &RateLimitError{
261+
StatusCode: resp.StatusCode,
262+
Message: errResp.Error,
263+
RetryAfter: retryAfter,
264+
}
265+
}
208266
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error)
209267
}
210268

211269
return respBody, nil
212270
}
213271

272+
// parseRetryAfter parses the Retry-After header value
273+
// It can be either a delay in seconds or an HTTP-date
274+
func parseRetryAfter(retryAfter string) int {
275+
// First try to parse as integer seconds
276+
if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds > 0 {
277+
return seconds
278+
}
279+
280+
// Try to parse as HTTP-date
281+
if t, err := http.ParseTime(retryAfter); err == nil {
282+
delay := time.Until(t).Seconds()
283+
if delay > 0 {
284+
return int(delay)
285+
}
286+
}
287+
288+
// Default to 60 seconds if we can't parse
289+
return 60
290+
}
291+
214292
// OIDC Client methods
215293

216294
// CreateClient creates a new OIDC client

internal/client/client_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,71 @@ func TestClient_UpdateClientAllowedUserGroups(t *testing.T) {
518518
err = c.UpdateClientAllowedUserGroups("test-client-id", []string{"group1", "group2"})
519519
assert.NoError(t, err)
520520
}
521+
522+
func TestClient_RateLimitHandling(t *testing.T) {
523+
attempts := 0
524+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
525+
attempts++
526+
if attempts < 3 {
527+
// Simulate rate limit for first 2 attempts
528+
w.Header().Set("Content-Type", "application/json")
529+
w.Header().Set("Retry-After", "1") // 1 second retry
530+
w.WriteHeader(http.StatusTooManyRequests)
531+
fmt.Fprint(w, `{"error": "Rate limit exceeded"}`)
532+
return
533+
}
534+
// Success on third attempt
535+
w.Header().Set("Content-Type", "application/json")
536+
json.NewEncoder(w).Encode(&client.OIDCClient{
537+
ID: "test-client-id",
538+
Name: "Test Client",
539+
})
540+
}))
541+
defer server.Close()
542+
543+
c, err := client.NewClient(server.URL, "test-token", false, 30)
544+
require.NoError(t, err)
545+
546+
start := time.Now()
547+
result, err := c.GetClient("test-client-id")
548+
elapsed := time.Since(start)
549+
550+
assert.NoError(t, err)
551+
assert.NotNil(t, result)
552+
assert.Equal(t, "test-client-id", result.ID)
553+
assert.Equal(t, "Test Client", result.Name)
554+
assert.Equal(t, 3, attempts)
555+
// Should have waited at least 2 seconds (2 retries with 1 second each)
556+
assert.True(t, elapsed >= 2*time.Second, "Expected at least 2 seconds elapsed, got %v", elapsed)
557+
}
558+
559+
func TestClient_RateLimitWithoutRetryAfter(t *testing.T) {
560+
attempts := 0
561+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
562+
attempts++
563+
if attempts < 2 {
564+
// Simulate rate limit without Retry-After header
565+
w.Header().Set("Content-Type", "application/json")
566+
w.WriteHeader(http.StatusTooManyRequests)
567+
fmt.Fprint(w, `{"error": "Rate limit exceeded"}`)
568+
return
569+
}
570+
// Success on second attempt
571+
w.Header().Set("Content-Type", "application/json")
572+
json.NewEncoder(w).Encode(&client.OIDCClient{
573+
ID: "test-client-id",
574+
Name: "Test Client",
575+
})
576+
}))
577+
defer server.Close()
578+
579+
c, err := client.NewClient(server.URL, "test-token", false, 30)
580+
require.NoError(t, err)
581+
582+
result, err := c.GetClient("test-client-id")
583+
584+
assert.NoError(t, err)
585+
assert.NotNil(t, result)
586+
assert.Equal(t, "test-client-id", result.ID)
587+
assert.Equal(t, 2, attempts)
588+
}

0 commit comments

Comments
 (0)