Back to all posts
Proxy-Aware Token Bucket Rate Limiter in Go

Proxy-Aware Token Bucket Rate Limiter in Go

November 1, 2026

When you scrape or call APIs at scale, two constraints collide: the target’s rate limits and the proxy’s IP reputation. A token bucket gives you smooth, configurable throughput, while a rotating proxy lets you spread requests across many IPs. Combining them means you can back off locally before you hit a remote 429, and you can trigger a fresh IP the moment your bucket runs dry.

Understanding Token Bucket Algorithm

The token bucket is a classic rate‑limiting primitive. Imagine a bucket that holds capacity tokens. Every refill interval you add rate tokens (capped at capacity). Each request consumes one token. If the bucket is empty, the caller blocks until a token appears. This shape gives you burst tolerance (up to capacity) and a steady long‑term rate.

Key parameters:

  • capacity – maximum burst size.
  • rate – tokens added per second.
  • refill interval – how often you add tokens (usually 1 s).

Designing a Proxy‑Aware Rate Limiter

A proxy‑aware limiter has three responsibilities:

  1. Enforce local rate limits per logical client (e.g., per scraper worker).
  2. Attach a proxy to each outbound HTTP request.
  3. Rotate the proxy IP when the bucket is exhausted or when the remote server signals a block.

Core Components

Component Role
Limiter Holds a rate.Limiter per session and exposes Wait(context)
ProxyPool Stores proxy URLs and the associated session IDs
Rotator Calls the provider’s rotate endpoint when needed

Integrating Proxy Rotation

The rotation trigger can be:

  • Proactive: after N requests or when Wait returns a long delay.
  • Reactive: on HTTP 429, 403, or connection errors.

Both strategies are easy to express in Go because the limiter’s Wait returns the time slept. If that time exceeds a threshold, you rotate.

Implementation in Go

Below is a complete, runnable example. It uses only the standard library and golang.org/x/time/rate.

Project Structure

proxy-limiter/
├── main.go
├── limiter.go
├── proxy.go
└── rotator.go

Token Bucket Implementation

limiter.go

package main

import (
	"context"
	"time"
	"golang.org/x/time/rate"
)

// SessionLimiter wraps a token bucket for a single proxy session.
type SessionLimiter struct {
	limiter *rate.Limiter
}

func NewSessionLimiter(rps float64, burst int) *SessionLimiter {
	return &SessionLimiter{limiter: rate.NewLimiter(rate.Limit(rps), burst)}
}

// Wait blocks until a token is available.
func (sl *SessionLimiter) Wait(ctx context.Context) error {
	return sl.limiter.Wait(ctx)
}

HTTP Client with Proxy

proxy.go

package main

import (
	"net/http"
	"net/url"
)

// ProxyConfig holds the static parts of the proxy URL.
// When using a rotating proxy service such as RoProxy, you configure the proxy URL as:
// http://USERNAME:PASSWORD@GATEWAY_HOST:HTTP_PORT
// Replace the placeholders with your actual credentials.
type ProxyConfig struct {
	GatewayHost string
	HTTPPort    string
	Username    string
	Password    string
}

func (pc *ProxyConfig) ProxyURL() *url.URL {
	return &url.URL{
		Scheme: "http",
		User:   url.UserPassword(pc.Username, pc.Password),
		Host:   pc.GatewayHost + ":" + pc.HTTPPort,
	}
}

// NewClient returns an http.Client that routes through the given proxy.
func NewClient(proxyURL *url.URL) *http.Client {
	transport := &http.Transport{
		Proxy: http.ProxyURL(proxyURL),
	}
	return &http.Client{Transport: transport, Timeout: 10 * time.Second}
}

Rotating IP via API

rotator.go

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"time"
)

// Rotator calls the provider’s rotate endpoint.
// Only the two documented endpoints are used.
type Rotator struct {
	APIKey     string
	SessionID  string
	HTTPClient *http.Client
}

func NewRotator(apiKey, sessionID string, client *http.Client) *Rotator {
	return &Rotator{APIKey: apiKey, SessionID: sessionID, HTTPClient: client}
}

// Rotate triggers an immediate IP change for the session.
func (r *Rotator) Rotate(ctx context.Context) error {
	url := "https://backend-api.ro-proxy.com/api/session/" + r.SessionID + "/rotate"
	req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+r.APIKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := r.HTTPClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 300 {
		return &rotateError{StatusCode: resp.StatusCode}
	}
	return nil
}

type rotateError struct { StatusCode int }
func (e *rotateError) Error() string { return "rotate failed: " + string(rune(e.StatusCode)) }

Putting It Together

main.go

package main

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

func main() {
	// --- Configuration (replace placeholders) ---
	proxyCfg := ProxyConfig{
		GatewayHost: "GATEWAY_HOST",
		HTTPPort:    "HTTP_PORT",
		Username:    "USERNAME",
		Password:    "PASSWORD",
	}
	apiKey := "API_KEY"
	sessionID := "SESSION_ID"

	// Build proxy‑aware HTTP client
	proxyURL := proxyCfg.ProxyURL()
	client := NewClient(proxyURL)

	// Limiter: 5 requests/sec, burst of 10
	limiter := NewSessionLimiter(5, 10)

	// Rotator uses a plain client (no proxy) to talk to the API
	apiClient := &http.Client{Timeout: 5 * time.Second}
	rotator := NewRotator(apiKey, sessionID, apiClient)

	// Target endpoint for demo
	target := "https://httpbin.org/ip"

	for i := 0; i < 30; i++ {
		ctx := context.Background()
		// Wait for token
		if err := limiter.Wait(ctx); err != nil {
			log.Fatalf("limiter wait: %v", err)
		}

		// Perform request
		req, _ := http.NewRequestWithContext(ctx, "GET", target, nil)
		resp, err := client.Do(req)
		if err != nil {
			log.Printf("request error: %v", err)
			continue
		}
		
		// Check for rate‑limit signals
		if resp.StatusCode == 429 || resp.StatusCode == 403 {
			log.Printf("received %d, rotating IP", resp.StatusCode)
			if err := rotator.Rotate(ctx); err != nil {
				log.Printf("rotate failed: %v", err)
			}
			resp.Body.Close()
			continue
		}

		// Read and log exit IP
		var data map[string]string
		if err := json.NewDecoder(resp.Body).Decode(&data); err == nil {
			fmt.Printf("request %d: exit IP %s\n", i+1, data["origin"])
		}
		resp.Body.Close()

		// Optional: proactive rotation after 15 requests
		if (i+1)%15 == 0 {
			log.Printf("proactive rotation after %d requests", i+1)
			if err := rotator.Rotate(ctx); err != nil {
				log.Printf("rotate failed: %v", err)
			}
		}
	}
}

Testing the Limiter

Run the program and watch the output. You should see a steady stream of requests, each logging the exit IP returned by httpbin.org/ip. When a 429 or 403 appears, the rotator fires and the next request shows a new IP. The proactive rotation every 15 requests demonstrates how you can schedule IP changes based on your own heuristics.

Verify Exit IP

The example uses https://httpbin.org/ip (or https://api.ipify.org?format=json) to confirm the current proxy IP. Replace the target URL with your real endpoint when you go live.

Best Practices

  • Separate limiters per session – each proxy session gets its own bucket so a single noisy IP doesn’t starve the others.
  • Tune burst vs. rate – start with a low burst (5‑10) and increase only after you observe clean responses.
  • Handle rotation errors – if the rotate call fails, back off and retry; don’t hammer the API.
  • Monitor latency – long Wait times indicate you’re hitting the local limit; consider adding more sessions.
  • Respect target policies – even with rotation, honor Retry-After headers and robots.txt.

Conclusion

A token bucket gives you predictable, configurable throughput, and coupling it with on‑demand IP rotation lets you stay under remote rate limits while keeping a healthy IP reputation. The pattern above works with any rotating proxy provider that offers a simple rotate endpoint. RoProxy provides rotating sessions that work well with this pattern, letting you focus on data collection instead of infrastructure plumbing.