Rate Limiting & Retries

7 minute read

Rate Limiting & Retries Overview

Production APIs impose rate limits. Your provider must respect these limits and retry transient failures intelligently.

Rate Limiting

Token Bucket Rate Limiter

import "golang.org/x/time/rate"

type Client struct {
    httpClient  *http.Client
    rateLimiter *rate.Limiter
    apiKey      string
    baseURL     string
}

func NewClient(apiKey string, rateLimit float64) (*Client, error) {
    // Allow 100 requests per second with burst of 10
    limiter := rate.NewLimiter(rate.Limit(rateLimit), 10)
    
    return &Client{
        httpClient: &http.Client{
            Timeout: 30 * time.Second,
        },
        rateLimiter: limiter,
        apiKey:      apiKey,
        baseURL:     "https://api.example.com",
    }, nil
}

func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) {
    // Wait for rate limit
    if err := c.rateLimiter.Wait(ctx); err != nil {
        return nil, fmt.Errorf("rate limiter cancelled: %w", err)
    }
    
    // Make request
    return c.httpClient.Do(req)
}

Configurable Rate Limiting

type RateLimitConfig struct {
    RequestsPerSecond float64
    Burst             int
}

func NewClient(apiKey string, rateLimitConfig *RateLimitConfig) (*Client, error) {
    var limiter *rate.Limiter
    
    if rateLimitConfig != nil {
        limiter = rate.NewLimiter(
            rate.Limit(rateLimitConfig.RequestsPerSecond),
            rateLimitConfig.Burst,
        )
    } else {
        // Default: 100 req/s, burst of 10
        limiter = rate.NewLimiter(100, 10)
    }
    
    return &Client{
        httpClient:  &http.Client{Timeout: 30 * time.Second},
        rateLimiter: limiter,
        apiKey:      apiKey,
    }, nil
}

Respect API Rate Limit Headers

func (c *Client) updateRateLimitFromHeaders(resp *http.Response) {
    if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining != "" {
        if n, err := strconv.Atoi(remaining); err == nil && n == 0 {
            // Parse reset time
            if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" {
                if resetTime, err := strconv.ParseInt(reset, 10, 64); err == nil {
                    waitTime := time.Unix(resetTime, 0).Sub(time.Now())
                    if waitTime > 0 {
                        tflog.Warn(context.Background(), "Rate limit exhausted, waiting", map[string]interface{}{
                            "wait_seconds": waitTime.Seconds(),
                        })
                        time.Sleep(waitTime)
                    }
                }
            }
        }
    }
}

Exponential Backoff

Basic Exponential Backoff

func (c *Client) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
    maxRetries := 3
    baseDelay := 1 * time.Second
    
    var resp *http.Response
    var err error
    
    for attempt := 0; attempt <= maxRetries; attempt++ {
        resp, err = c.httpClient.Do(req)
        
        // Success
        if err == nil && resp.StatusCode < 500 && resp.StatusCode != 429 {
            return resp, nil
        }
        
        // Don't retry on last attempt
        if attempt == maxRetries {
            break
        }
        
        // Calculate backoff delay: 1s, 2s, 4s, 8s...
        delay := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
        
        // Add jitter to avoid thundering herd
        jitter := time.Duration(rand.Int63n(int64(delay / 2)))
        delay += jitter
        
        tflog.Debug(ctx, "Retrying request", map[string]interface{}{
            "attempt": attempt + 1,
            "delay":   delay.String(),
        })
        
        // Wait before retry
        select {
        case <-time.After(delay):
            // Continue to next attempt
        case <-ctx.Done():
            return nil, ctx.Err()
        }
        
        // Clone request body for retry
        if req.Body != nil && req.GetBody != nil {
            req.Body, _ = req.GetBody()
        }
    }
    
    return resp, err
}

Configurable Retry Logic

type RetryConfig struct {
    MaxRetries int
    MinWait    time.Duration
    MaxWait    time.Duration
    Multiplier float64
}

func DefaultRetryConfig() *RetryConfig {
    return &RetryConfig{
        MaxRetries: 3,
        MinWait:    1 * time.Second,
        MaxWait:    30 * time.Second,
        Multiplier: 2.0,
    }
}

func (c *Client) doWithRetry(ctx context.Context, req *http.Request, config *RetryConfig) (*http.Response, error) {
    if config == nil {
        config = DefaultRetryConfig()
    }
    
    var resp *http.Response
    var err error
    
    for attempt := 0; attempt <= config.MaxRetries; attempt++ {
        resp, err = c.httpClient.Do(req)
        
        // Check if retry is needed
        if !c.shouldRetry(resp, err) {
            return resp, err
        }
        
        // Don't retry on last attempt
        if attempt == config.MaxRetries {
            break
        }
        
        // Calculate backoff
        wait := c.calculateBackoff(attempt, config)
        
        tflog.Debug(ctx, "Retrying request", map[string]interface{}{
            "attempt":     attempt + 1,
            "max_retries": config.MaxRetries,
            "wait":        wait.String(),
        })
        
        // Wait with context cancellation support
        if err := c.waitWithContext(ctx, wait); err != nil {
            return nil, err
        }
        
        // Prepare for retry
        if req.Body != nil && req.GetBody != nil {
            req.Body, _ = req.GetBody()
        }
    }
    
    return resp, err
}

func (c *Client) shouldRetry(resp *http.Response, err error) bool {
    // Network errors are retryable
    if err != nil {
        return true
    }
    
    // Rate limit errors
    if resp.StatusCode == 429 {
        return true
    }
    
    // Server errors (5xx)
    if resp.StatusCode >= 500 {
        return true
    }
    
    return false
}

func (c *Client) calculateBackoff(attempt int, config *RetryConfig) time.Duration {
    // Exponential backoff: MinWait * Multiplier^attempt
    wait := float64(config.MinWait) * math.Pow(config.Multiplier, float64(attempt))
    
    // Cap at MaxWait
    if wait > float64(config.MaxWait) {
        wait = float64(config.MaxWait)
    }
    
    // Add jitter (random 0-50% of wait time)
    jitter := time.Duration(rand.Int63n(int64(wait) / 2))
    
    return time.Duration(wait) + jitter
}

func (c *Client) waitWithContext(ctx context.Context, duration time.Duration) error {
    select {
    case <-time.After(duration):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

Retry Library Integration

Using hashicorp/go-retryablehttp

import "github.com/hashicorp/go-retryablehttp"

func NewClient(apiKey string) (*Client, error) {
    retryClient := retryablehttp.NewClient()
    
    // Configure retry behavior
    retryClient.RetryMax = 3
    retryClient.RetryWaitMin = 1 * time.Second
    retryClient.RetryWaitMax = 30 * time.Second
    retryClient.Logger = nil  // Disable logging
    
    // Custom retry policy
    retryClient.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
        // Don't retry on context cancellation
        if ctx.Err() != nil {
            return false, ctx.Err()
        }
        
        // Retry on network errors
        if err != nil {
            return true, nil
        }
        
        // Retry on rate limit or server errors
        if resp.StatusCode == 429 || resp.StatusCode >= 500 {
            return true, nil
        }
        
        return false, nil
    }
    
    // Custom backoff function
    retryClient.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
        // Check if API provides Retry-After header
        if resp != nil {
            if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
                if seconds, err := strconv.Atoi(retryAfter); err == nil {
                    return time.Duration(seconds) * time.Second
                }
            }
        }
        
        // Exponential backoff with jitter
        mult := math.Pow(2, float64(attemptNum))
        wait := time.Duration(mult) * min
        if wait > max {
            wait = max
        }
        
        // Add jitter
        jitter := time.Duration(rand.Int63n(int64(wait) / 4))
        return wait + jitter
    }
    
    return &Client{
        httpClient: retryClient.StandardClient(),
        apiKey:     apiKey,
    }, nil
}

Idempotency

Idempotency Keys

func (c *Client) CreateDatabase(ctx context.Context, input CreateDatabaseInput) (*Database, error) {
    // Generate idempotency key
    idempotencyKey := uuid.New().String()
    
    body, _ := json.Marshal(input)
    req, _ := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/databases", bytes.NewReader(body))
    
    // Add idempotency key header
    req.Header.Set("Idempotency-Key", idempotencyKey)
    req.Header.Set("Authorization", "Bearer "+c.apiKey)
    req.Header.Set("Content-Type", "application/json")
    
    resp, err := c.doWithRetry(ctx, req, nil)
    if err != nil {
        return nil, err
    }
    
    var db Database
    json.NewDecoder(resp.Body).Decode(&db)
    
    return &db, nil
}

Check Before Create

func (c *Client) CreateDatabase(ctx context.Context, input CreateDatabaseInput) (*Database, error) {
    // Check if database with same name already exists
    existing, err := c.GetDatabaseByName(ctx, input.Name)
    if err == nil && existing != nil {
        // Already exists, return it
        tflog.Info(ctx, "Database already exists, reusing", map[string]interface{}{
            "database_id": existing.ID,
        })
        return existing, nil
    }
    
    // Create new database
    return c.createDatabaseInternal(ctx, input)
}

Circuit Breaker

Simple Circuit Breaker

type CircuitBreaker struct {
    maxFailures  int
    resetTimeout time.Duration
    failures     int
    lastFailTime time.Time
    state        string  // "closed", "open", "half-open"
    mu           sync.Mutex
}

func NewCircuitBreaker(maxFailures int, resetTimeout time.Duration) *CircuitBreaker {
    return &CircuitBreaker{
        maxFailures:  maxFailures,
        resetTimeout: resetTimeout,
        state:        "closed",
    }
}

func (cb *CircuitBreaker) Call(fn func() error) error {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    
    // Check circuit state
    if cb.state == "open" {
        // Check if we should try again
        if time.Since(cb.lastFailTime) > cb.resetTimeout {
            cb.state = "half-open"
        } else {
            return fmt.Errorf("circuit breaker open")
        }
    }
    
    // Try operation
    err := fn()
    
    if err != nil {
        cb.failures++
        cb.lastFailTime = time.Now()
        
        if cb.failures >= cb.maxFailures {
            cb.state = "open"
        }
        
        return err
    }
    
    // Success - reset circuit
    cb.failures = 0
    cb.state = "closed"
    
    return nil
}

// Usage
type Client struct {
    httpClient     *http.Client
    circuitBreaker *CircuitBreaker
}

func (c *Client) GetDatabase(ctx context.Context, id string) (*Database, error) {
    var db *Database
    
    err := c.circuitBreaker.Call(func() error {
        var err error
        db, err = c.getDatabaseInternal(ctx, id)
        return err
    })
    
    return db, err
}

Testing Retry Logic

Test Retries

func TestClient_Retry(t *testing.T) {
    attempts := 0
    
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        attempts++
        
        // Fail first 2 attempts
        if attempts < 3 {
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        
        // Succeed on 3rd attempt
        w.WriteHeader(http.StatusOK)
        json.NewEncoder(w).Encode(Database{
            ID:   "db-123",
            Name: "test-db",
        })
    }))
    defer server.Close()
    
    client, _ := NewClient(&ClientConfig{
        APIKey:  "test-key",
        BaseURL: server.URL,
        RetryConfig: &RetryConfig{
            MaxRetries: 3,
            MinWait:    10 * time.Millisecond,
            MaxWait:    100 * time.Millisecond,
            Multiplier: 2.0,
        },
    })
    
    db, err := client.GetDatabase(context.Background(), "db-123")
    
    assert.NoError(t, err)
    assert.Equal(t, "db-123", db.ID)
    assert.Equal(t, 3, attempts, "Should have made 3 attempts")
}

Test Rate Limiting

func TestClient_RateLimit(t *testing.T) {
    client, _ := NewClient("test-key", 10.0)  // 10 req/s
    
    start := time.Now()
    
    // Make 15 requests
    for i := 0; i < 15; i++ {
        _, _ = client.GetDatabase(context.Background(), "db-123")
    }
    
    duration := time.Since(start)
    
    // Should take at least 1 second (rate limited to 10 req/s + burst)
    assert.Greater(t, duration, 500*time.Millisecond)
}

Best Practices

1. Use Exponential Backoff with Jitter

// ✅ Good: Exponential backoff with jitter
delay := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
time.Sleep(delay + jitter)

// ❌ Bad: Fixed delay
time.Sleep(1 * time.Second)

2. Respect Context Cancellation

// ✅ Good: Check context
select {
case <-time.After(delay):
    // Continue
case <-ctx.Done():
    return ctx.Err()
}

// ❌ Bad: Block without checking context
time.Sleep(delay)

3. Make Retries Configurable

// ✅ Good: Configurable
type RetryConfig struct {
    MaxRetries int
    MinWait    time.Duration
    MaxWait    time.Duration
}

// ❌ Bad: Hard-coded
const maxRetries = 3

4. Log Retry Attempts

// ✅ Good: Log retries
tflog.Debug(ctx, "Retrying request", map[string]interface{}{
    "attempt": attempt,
    "wait":    delay.String(),
    "error":   err.Error(),
})

// ❌ Bad: Silent retries
// No logging

Key Takeaways

  • ✅ Implement rate limiting to respect API quotas
  • ✅ Use exponential backoff for retries
  • ✅ Add jitter to prevent thundering herd
  • ✅ Respect Retry-After headers
  • ✅ Make retry logic configurable
  • ✅ Support context cancellation
  • ✅ Only retry transient failures
  • ✅ Use idempotency keys for safe retries
  • ✅ Log retry attempts for debugging
  • ✅ Consider circuit breakers for cascading failures

What’s Next?

Practice building a complete API client with all the patterns you’ve learned.

Next: API Client Lab →


Previous: ← API Error Handling

Last updated: July 2, 2026

Updated: