Table of contents

How do I Handle API Rate Limits When Scraping in Go?

API rate limiting is a critical aspect of responsible web scraping that prevents your applications from overwhelming target servers and ensures sustainable data collection. When scraping APIs in Go, implementing proper rate limiting strategies helps you avoid getting blocked, reduces server load, and maintains good relationships with API providers.

Understanding API Rate Limits

Rate limits are restrictions imposed by APIs to control the number of requests a client can make within a specific time window. Common rate limiting patterns include:

  • Requests per second (RPS): Maximum number of requests allowed per second
  • Requests per minute/hour: Longer time windows with higher quotas
  • Burst limits: Short-term allowances for higher request rates
  • Concurrent connections: Maximum simultaneous active requests

Basic Rate Limiting with time.Ticker

The simplest approach to rate limiting in Go uses the time.Ticker to control request intervals:

package main

import (
    "fmt"
    "net/http"
    "time"
)

type RateLimitedClient struct {
    client *http.Client
    ticker *time.Ticker
}

func NewRateLimitedClient(requestsPerSecond float64) *RateLimitedClient {
    interval := time.Duration(float64(time.Second) / requestsPerSecond)
    return &RateLimitedClient{
        client: &http.Client{Timeout: 30 * time.Second},
        ticker: time.NewTicker(interval),
    }
}

func (rlc *RateLimitedClient) Get(url string) (*http.Response, error) {
    <-rlc.ticker.C // Wait for the next tick
    return rlc.client.Get(url)
}

func (rlc *RateLimitedClient) Close() {
    rlc.ticker.Stop()
}

func main() {
    client := NewRateLimitedClient(2.0) // 2 requests per second
    defer client.Close()

    urls := []string{
        "https://api.example.com/data/1",
        "https://api.example.com/data/2",
        "https://api.example.com/data/3",
    }

    for _, url := range urls {
        resp, err := client.Get(url)
        if err != nil {
            fmt.Printf("Error fetching %s: %v\n", url, err)
            continue
        }
        resp.Body.Close()
        fmt.Printf("Successfully fetched: %s\n", url)
    }
}

Advanced Rate Limiting with Token Bucket Algorithm

For more sophisticated rate limiting that supports burst requests, implement a token bucket algorithm:

package main

import (
    "context"
    "fmt"
    "net/http"
    "sync"
    "time"
)

type TokenBucket struct {
    capacity int
    tokens   int
    refill   time.Duration
    mutex    sync.Mutex
    lastRefill time.Time
}

func NewTokenBucket(capacity int, refillRate time.Duration) *TokenBucket {
    return &TokenBucket{
        capacity:   capacity,
        tokens:     capacity,
        refill:     refillRate,
        lastRefill: time.Now(),
    }
}

func (tb *TokenBucket) Take() bool {
    tb.mutex.Lock()
    defer tb.mutex.Unlock()

    now := time.Now()
    elapsed := now.Sub(tb.lastRefill)
    tokensToAdd := int(elapsed / tb.refill)

    if tokensToAdd > 0 {
        tb.tokens = min(tb.capacity, tb.tokens+tokensToAdd)
        tb.lastRefill = now
    }

    if tb.tokens > 0 {
        tb.tokens--
        return true
    }
    return false
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

type TokenBucketClient struct {
    client *http.Client
    bucket *TokenBucket
}

func NewTokenBucketClient(capacity int, refillRate time.Duration) *TokenBucketClient {
    return &TokenBucketClient{
        client: &http.Client{Timeout: 30 * time.Second},
        bucket: NewTokenBucket(capacity, refillRate),
    }
}

func (tbc *TokenBucketClient) Get(ctx context.Context, url string) (*http.Response, error) {
    for {
        if tbc.bucket.Take() {
            req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
            if err != nil {
                return nil, err
            }
            return tbc.client.Do(req)
        }

        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(100 * time.Millisecond):
            // Wait a bit before trying again
        }
    }
}

Implementing Exponential Backoff

When dealing with rate limit errors (HTTP 429), implement exponential backoff to gradually increase wait times:

package main

import (
    "context"
    "fmt"
    "math"
    "net/http"
    "strconv"
    "time"
)

type BackoffClient struct {
    client      *http.Client
    maxRetries  int
    baseDelay   time.Duration
    maxDelay    time.Duration
}

func NewBackoffClient(maxRetries int, baseDelay, maxDelay time.Duration) *BackoffClient {
    return &BackoffClient{
        client:     &http.Client{Timeout: 30 * time.Second},
        maxRetries: maxRetries,
        baseDelay:  baseDelay,
        maxDelay:   maxDelay,
    }
}

func (bc *BackoffClient) GetWithRetry(ctx context.Context, url string) (*http.Response, error) {
    var resp *http.Response
    var err error

    for attempt := 0; attempt <= bc.maxRetries; attempt++ {
        req, reqErr := http.NewRequestWithContext(ctx, "GET", url, nil)
        if reqErr != nil {
            return nil, reqErr
        }

        resp, err = bc.client.Do(req)
        if err != nil {
            return nil, err
        }

        // Success case
        if resp.StatusCode < 400 {
            return resp, nil
        }

        // Handle rate limiting
        if resp.StatusCode == 429 {
            resp.Body.Close()

            if attempt == bc.maxRetries {
                return nil, fmt.Errorf("max retries exceeded for URL: %s", url)
            }

            delay := bc.calculateDelay(attempt, resp)
            fmt.Printf("Rate limited, waiting %v before retry %d\n", delay, attempt+1)

            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(delay):
                continue
            }
        }

        // Other errors
        resp.Body.Close()
        return nil, fmt.Errorf("HTTP error %d for URL: %s", resp.StatusCode, url)
    }

    return resp, err
}

func (bc *BackoffClient) calculateDelay(attempt int, resp *http.Response) time.Duration {
    // Check for Retry-After header
    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
    delay := time.Duration(math.Pow(2, float64(attempt))) * bc.baseDelay
    if delay > bc.maxDelay {
        delay = bc.maxDelay
    }

    // Add jitter (±25%)
    jitter := time.Duration(float64(delay) * 0.25 * (2*time.Now().UnixNano()%2 - 1) / 1e9)
    return delay + jitter
}

Comprehensive Rate Limiting Solution

Here's a production-ready implementation that combines multiple strategies:

package main

import (
    "context"
    "fmt"
    "net/http"
    "sync"
    "time"
)

type RateLimiter struct {
    bucket     *TokenBucket
    lastReq    time.Time
    minDelay   time.Duration
    mutex      sync.Mutex
}

func NewRateLimiter(rps float64, burst int) *RateLimiter {
    interval := time.Duration(float64(time.Second) / rps)
    return &RateLimiter{
        bucket:   NewTokenBucket(burst, interval),
        minDelay: interval,
    }
}

func (rl *RateLimiter) Wait(ctx context.Context) error {
    rl.mutex.Lock()
    defer rl.mutex.Unlock()

    now := time.Now()
    elapsed := now.Sub(rl.lastReq)

    if elapsed < rl.minDelay {
        waitTime := rl.minDelay - elapsed
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(waitTime):
        }
    }

    // Wait for token if bucket is empty
    for !rl.bucket.Take() {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(50 * time.Millisecond):
        }
    }

    rl.lastReq = time.Now()
    return nil
}

type ScrapingClient struct {
    client      *http.Client
    rateLimiter *RateLimiter
    backoff     *BackoffClient
}

func NewScrapingClient(rps float64, burst int) *ScrapingClient {
    return &ScrapingClient{
        client:      &http.Client{Timeout: 30 * time.Second},
        rateLimiter: NewRateLimiter(rps, burst),
        backoff:     NewBackoffClient(3, 1*time.Second, 30*time.Second),
    }
}

func (sc *ScrapingClient) Get(ctx context.Context, url string) (*http.Response, error) {
    // Apply rate limiting
    if err := sc.rateLimiter.Wait(ctx); err != nil {
        return nil, err
    }

    // Make request with retry logic
    return sc.backoff.GetWithRetry(ctx, url)
}

// Example usage with concurrent scraping
func main() {
    client := NewScrapingClient(5.0, 10) // 5 RPS, burst of 10

    urls := []string{
        "https://api.example.com/users/1",
        "https://api.example.com/users/2",
        "https://api.example.com/users/3",
        "https://api.example.com/users/4",
        "https://api.example.com/users/5",
    }

    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()

    var wg sync.WaitGroup
    semaphore := make(chan struct{}, 3) // Limit concurrent requests

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()

            semaphore <- struct{}{} // Acquire
            defer func() { <-semaphore }() // Release

            resp, err := client.Get(ctx, u)
            if err != nil {
                fmt.Printf("Error fetching %s: %v\n", u, err)
                return
            }
            defer resp.Body.Close()

            fmt.Printf("Successfully fetched %s (Status: %d)\n", u, resp.StatusCode)
        }(url)
    }

    wg.Wait()
    fmt.Println("All requests completed")
}

Monitoring and Observability

Add logging and metrics to monitor your rate limiting effectiveness:

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "sync/atomic"
    "time"
)

type Metrics struct {
    RequestsTotal    int64
    RequestsLimited  int64
    RequestsRetried  int64
    RequestsFailed   int64
}

func (m *Metrics) LogStats() {
    total := atomic.LoadInt64(&m.RequestsTotal)
    limited := atomic.LoadInt64(&m.RequestsLimited)
    retried := atomic.LoadInt64(&m.RequestsRetried)
    failed := atomic.LoadInt64(&m.RequestsFailed)

    fmt.Printf("Stats - Total: %d, Limited: %d, Retried: %d, Failed: %d\n", 
        total, limited, retried, failed)
}

type MonitoredClient struct {
    client  *ScrapingClient
    metrics *Metrics
}

func NewMonitoredClient(rps float64, burst int) *MonitoredClient {
    return &MonitoredClient{
        client:  NewScrapingClient(rps, burst),
        metrics: &Metrics{},
    }
}

func (mc *MonitoredClient) Get(ctx context.Context, url string) (*http.Response, error) {
    atomic.AddInt64(&mc.metrics.RequestsTotal, 1)

    start := time.Now()
    resp, err := mc.client.Get(ctx, url)
    duration := time.Since(start)

    if err != nil {
        atomic.AddInt64(&mc.metrics.RequestsFailed, 1)
        log.Printf("Request failed for %s after %v: %v", url, duration, err)
        return nil, err
    }

    if resp.StatusCode == 429 {
        atomic.AddInt64(&mc.metrics.RequestsLimited, 1)
    }

    log.Printf("Request to %s completed in %v (Status: %d)", url, duration, resp.StatusCode)
    return resp, nil
}

func (mc *MonitoredClient) GetMetrics() *Metrics {
    return mc.metrics
}

Best Practices and Tips

1. Respect Rate Limit Headers

Always check and respect standard rate limiting headers: - X-RateLimit-Limit: Maximum requests allowed - X-RateLimit-Remaining: Remaining requests in current window - X-RateLimit-Reset: Time when the limit resets - Retry-After: Seconds to wait before next request

2. Use Context for Cancellation

Always use context.Context to handle timeouts and cancellations gracefully in your rate-limited requests.

3. Implement Circuit Breakers

For production systems, consider implementing circuit breaker patterns to temporarily stop requests when error rates exceed thresholds.

4. Monitor and Adjust

Continuously monitor your scraping performance and adjust rate limits based on API provider feedback and error rates.

Conclusion

Handling API rate limits effectively in Go requires a combination of proper timing control, retry mechanisms, and monitoring. By implementing token bucket algorithms, exponential backoff, and comprehensive error handling, you can build robust scraping applications that respect API constraints while maintaining high performance.

When implementing rate limiting for web scraping, consider combining these Go techniques with browser automation tools for JavaScript-heavy sites. For advanced scenarios involving monitoring network requests in Puppeteer or handling timeouts in Puppeteer, you may need to coordinate rate limiting across different scraping technologies.

Remember that responsible scraping not only prevents your applications from being blocked but also helps maintain a healthy ecosystem for data access across the web.

Try WebScraping.AI for Your Web Scraping Needs

Looking for a powerful web scraping solution? WebScraping.AI provides an LLM-powered API that combines Chromium JavaScript rendering with rotating proxies for reliable data extraction.

Key Features:

  • AI-powered extraction: Ask questions about web pages or extract structured data fields
  • JavaScript rendering: Full Chromium browser support for dynamic content
  • Rotating proxies: Datacenter and residential proxies from multiple countries
  • Easy integration: Simple REST API with SDKs for Python, Ruby, PHP, and more
  • Reliable & scalable: Built for developers who need consistent results

Getting Started:

Get page content with AI analysis:

curl "https://api.webscraping.ai/ai/question?url=https://example.com&question=What is the main topic?&api_key=YOUR_API_KEY"

Extract structured data:

curl "https://api.webscraping.ai/ai/fields?url=https://example.com&fields[title]=Page title&fields[price]=Product price&api_key=YOUR_API_KEY"

Try in request builder

Related Questions

Get Started Now

WebScraping.AI provides rotating proxies, Chromium rendering and built-in HTML parser for web scraping
Icon