v1.10.90-0e025b8
Skip to main content
TutorialGoCode

Go Proxy Client With Retry and Circuit Breaker

11 min read

By Hex Proxies Engineering Team

Go Proxy Client With Retry and Circuit Breaker

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.