v1.13.1-4059c5c7
Skip to main content
TutorialGoCode

Go Proxy Client With Retry and Circuit Breaker

11 min read

By Hex Proxies Engineering Team

Go's net/http client is already one of the best HTTP clients shipped with a standard library. It gives you keep-alive, HTTP/2, proxy support via Transport.Proxy, and a sane default connection pool. What it does not give you is intelligent retry, per-endpoint circuit breaking, or load-aware endpoint selection. This guide adds those, using github.com/sony/gobreaker for the breaker state machine.

The result is a proxy client that: picks the least-loaded endpoint per request, retries with exponential backoff on 5xx/429, opens a circuit breaker after 5 consecutive failures, and respects context.Context cancellation throughout.

Module setup

// go.mod
module github.com/example/hexproxy-client

go 1.22

require github.com/sony/gobreaker v1.0.0

gobreaker v1.0.0 is stable and has zero dependencies. Resist the urge to wire up Resilience4j-style configuration libraries — the whole point of Go's approach is that a few hundred lines of explicit code beat magic.

Endpoint state

Each endpoint owns (a) its own *http.Client with a proxy-configured Transport so connection pools do not collide, (b) a gobreaker instance, and (c) an atomic in-flight counter for load-aware picking. Sharing a single Transport across proxies would share the connection pool too, which defeats the purpose.

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"math/rand/v2"
	"net/http"
	"net/url"
	"sync"
	"sync/atomic"
	"time"

	"github.com/sony/gobreaker"
)

// ProxyEndpoint is an immutable proxy description.
type ProxyEndpoint struct {
	URL   string // "http://user:pass@gate.hexproxies.com:7777"
	Label string
}

// endpointState pairs a proxied http.Client with its circuit breaker.
type endpointState struct {
	ep      ProxyEndpoint
	client  *http.Client
	breaker *gobreaker.CircuitBreaker
	inFlight atomic.Int32
}

func newEndpointState(ep ProxyEndpoint) (*endpointState, error) {
	pu, err := url.Parse(ep.URL)
	if err != nil {
		return nil, fmt.Errorf("parse proxy url: %w", err)
	}
	transport := &http.Transport{
		Proxy:               http.ProxyURL(pu),
		MaxIdleConns:        100,
		MaxIdleConnsPerHost: 25,
		IdleConnTimeout:     90 * time.Second,
		ForceAttemptHTTP2:   true,
	}
	cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
		Name:        ep.Label,
		MaxRequests: 3,
		Interval:    60 * time.Second,
		Timeout:     30 * time.Second,
		ReadyToTrip: func(counts gobreaker.Counts) bool {
			return counts.ConsecutiveFailures >= 5
		},
	})
	return &endpointState{
		ep:      ep,
		client:  &http.Client{Transport: transport, Timeout: 20 * time.Second},
		breaker: cb,
	}, nil
}

Pool with load-aware picking

The pick method iterates all endpoints and returns the one with the fewest in-flight requests, skipping any whose breaker is open. This is an O(N) operation but N is small (typically 2-20 gateways), so the simpler algorithm is the right one. If you have hundreds of endpoints, switch to a min-heap.

// ProxyPool fans requests across a set of proxy endpoints.
type ProxyPool struct {
	mu    sync.RWMutex
	nodes []*endpointState
}

func NewProxyPool(eps []ProxyEndpoint) (*ProxyPool, error) {
	if len(eps) == 0 {
		return nil, errors.New("at least one endpoint required")
	}
	nodes := make([]*endpointState, 0, len(eps))
	for _, ep := range eps {
		s, err := newEndpointState(ep)
		if err != nil {
			return nil, err
		}
		nodes = append(nodes, s)
	}
	return &ProxyPool{nodes: nodes}, nil
}

// pick returns the endpoint with the fewest in-flight requests that is
// not in an open-circuit state.
func (p *ProxyPool) pick() (*endpointState, error) {
	p.mu.RLock()
	defer p.mu.RUnlock()

	var best *endpointState
	bestLoad := int32(1 << 30)
	for _, n := range p.nodes {
		if n.breaker.State() == gobreaker.StateOpen {
			continue
		}
		load := n.inFlight.Load()
		if load < bestLoad {
			best = n
			bestLoad = load
		}
	}
	if best == nil {
		return nil, errors.New("all endpoints unavailable")
	}
	return best, nil
}

// Do executes the request with retry and circuit-break semantics.
// maxAttempts includes the initial try.
func (p *ProxyPool) Do(
	ctx context.Context,
	req *http.Request,
	maxAttempts int,
) (*http.Response, error) {
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		node, err := p.pick()
		if err != nil {
			lastErr = err
			select {
			case <-ctx.Done():
				return nil, ctx.Err()
			case <-time.After(backoff(attempt)):
				continue
			}
		}

		node.inFlight.Add(1)
		result, cbErr := node.breaker.Execute(func() (any, error) {
			resp, httpErr := node.client.Do(req.Clone(ctx))
			if httpErr != nil {
				return nil, httpErr
			}
			if resp.StatusCode == 429 || resp.StatusCode >= 500 {
				// Drain and close body so the connection can be reused.
				io.Copy(io.Discard, resp.Body)
				_ = resp.Body.Close()
				return nil, fmt.Errorf("retryable status %d", resp.StatusCode)
			}
			return resp, nil
		})
		node.inFlight.Add(-1)

		if cbErr == nil {
			return result.(*http.Response), nil
		}
		lastErr = cbErr

		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(backoff(attempt)):
		}
	}
	return nil, fmt.Errorf("all attempts failed: %w", lastErr)
}

func backoff(attempt int) time.Duration {
	base := time.Duration(1<<attempt) * 100 * time.Millisecond
	if base > 5*time.Second {
		base = 5 * time.Second
	}
	jitter := time.Duration(rand.Int64N(int64(base / 4)))
	return base + jitter
}

Driving the pool

The main function fires 500 concurrent requests through a 50-wide semaphore. The pool handles retries internally, so the caller only sees a final success or a wrapped error.

func main() {
	pool, err := NewProxyPool([]ProxyEndpoint{
		{URL: "http://USER:PASS@gate.hexproxies.com:7777", Label: "hex-us"},
		{URL: "http://USER:PASS@gate-eu.hexproxies.com:7777", Label: "hex-eu"},
	})
	if err != nil {
		panic(err)
	}

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

	var wg sync.WaitGroup
	sem := make(chan struct{}, 50) // global concurrency cap

	for i := 0; i < 500; i++ {
		wg.Add(1)
		sem <- struct{}{}
		go func(i int) {
			defer wg.Done()
			defer func() { <-sem }()

			req, _ := http.NewRequestWithContext(
				ctx, "GET",
				fmt.Sprintf("https://httpbin.org/ip?n=%d", i),
				nil,
			)
			resp, err := pool.Do(ctx, req, 4)
			if err != nil {
				fmt.Printf("req %d: %v\n", i, err)
				return
			}
			_, _ = io.Copy(io.Discard, resp.Body)
			_ = resp.Body.Close()
		}(i)
	}
	wg.Wait()
}

Circuit breaker tuning

The defaults — ConsecutiveFailures >= 5, 30-second timeout — are reasonable for residential proxies, where transient failures are common. For ISP proxies where failures indicate real problems, drop the threshold to 3 and extend the timeout to 2 minutes. Do not set the threshold to 1: you will flip the breaker on single-request hiccups and waste half your capacity.

What this client does not do

It does not retry on idempotent methods only — every request is retried. If you call POST /orders with this pool you could double-submit. Fix that by checking req.Method inside Do() and returning immediately on non-idempotent failures, or by requiring an Idempotency-Key header on writes. The scraping use case doesn't need this because reads are always safe to retry.

It also does not rotate session identity per request. For that, generate a per-session username (USER-session-N) and build a new endpoint per session. Hex Proxies gateway supports this natively. See the ISP vs residential guide to pick the right product.