Rate Limiting & Retries
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.
Previous: ← API Error Handling
Last updated: July 2, 2026