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:
- Enforce local rate limits per logical client (e.g., per scraper worker).
- Attach a proxy to each outbound HTTP request.
- 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
Waitreturns 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
Waittimes indicate you’re hitting the local limit; consider adding more sessions. - Respect target policies – even with rotation, honor
Retry-Afterheaders 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.