Help us shape the future of Go! Take the 2026 Go Developer Survey

proxypool

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 11 Imported by: 0

README

proxypool

CI Go Reference

A rotating proxy pool with cooldowns, for Go.

No dependencies. No background goroutines. Safe for concurrent use.

Why

Every project that runs more than one proxy ends up rewriting the same four things, each of them small and collectively a morning:

  • providers emit at least four different line formats, and none of them is the URL an HTTP client accepts
  • a blocked address should stop being handed out for a while, then come back on its own
  • credentials must never reach a log
  • when every address is cooling down, the caller needs to be told rather than handed one anyway

That's the whole scope. There are no health checks and no scoring, because whether an address is usable is something only your own traffic can answer. A liveness probe that passes proves nothing about the request you actually care about.

Install

go get github.com/Mootjelh/proxypool

Use

pool, err := proxypool.Load("proxies.txt")
if err != nil {
    return err
}

for {
    p, healthy := pool.Next()
    if !healthy {
        // Every address is cooling down. p is the one recovering soonest.
        time.Sleep(5 * time.Second)
        continue
    }

    transport, err := p.Transport()
    if err != nil {
        return err
    }
    client := &http.Client{Transport: transport, Timeout: 20 * time.Second}

    resp, err := client.Get(target)
    if err != nil || resp.StatusCode == http.StatusTooManyRequests {
        log.Printf("blocked on %s", p)   // credentials stripped, index kept
        pool.Block(p, 10*time.Minute)
        continue
    }
    ...
}

Rotating from net/http

The loop above is the same in every project that uses this, so it also comes as an http.RoundTripper. Each request takes the next address, and an address that comes back refused is sidelined:

client := &http.Client{
    Transport: &proxypool.Transport{Pool: pool, Cooldown: 10 * time.Minute},
    Timeout:   20 * time.Second,
}

resp, err := client.Get(target)   // rotation and cooldowns handled

What counts as a failure is your decision, because it depends entirely on the target. The default blocks the address on 403, on 429, and on any transport error including timeouts. It deliberately leaves alone:

  • 404 and the rest of 4xx, where the origin answered a question about a URL and the address delivered the request perfectly well
  • 5xx, because an origin having a bad minute would otherwise sideline your whole pool within seconds, leaving nothing to retry on when it recovers
  • a request you cancelled yourself, which says nothing about the address

Wrap the default rather than replacing it when a target has a signal of its own:

BlockOn: func(resp *http.Response, err error) bool {
    if proxypool.DefaultBlockOn(resp, err) {
        return true
    }
    return resp != nil && resp.StatusCode == http.StatusServiceUnavailable
},

BlockOn gets exactly what RoundTrip is about to return, so one of the two is always nil. It must not read the body: that body is yours, and a hook that reads it hands you an empty one.

Three things worth knowing before you use it:

  • Your own transport. Base func(Proxy) (http.RoundTripper, error) is the seam for a browser-grade TLS client, a SOCKS dialer, or anything instrumented. It is called once per address and the result is kept, because rebuilding per request throws away the connection pool and every request then pays a fresh handshake.
  • Redirects spread out. http.Client calls RoundTrip once per hop, so a redirect chain crosses several addresses. Usually that is the point. If a chain has to stay on one address, follow it yourself.
  • No retry. A failed request comes back as it is, with the address already sidelined so the next attempt lands elsewhere. Retrying inside a RoundTripper means guessing how many attempts you want and whether the body can be replayed, and neither is knowable from in there.

When every address is cooling down the request still goes out, through the one recovering soonest. Sleeping would spend a latency budget you never agreed to, and failing would turn a busy pool into an outage. An empty pool is a different thing and returns ErrPoolEmpty, since no amount of waiting produces an address.

Input formats

Normalize takes what providers hand out:

input result
1.2.3.4:8080 http://1.2.3.4:8080
gw.example.com:7000:user:pass http://user:pass@gw.example.com:7000
user:pass@1.2.3.4:8080 http://user:pass@1.2.3.4:8080
socks5://user:pass@host:1080 unchanged

Load reads a file of them, skipping blank lines and # comments. A line it can't parse is an error naming the line number rather than a silent skip. A proxy list is configuration, and quietly running on nine of ten addresses is worse than not starting at all.

Changing the list while it runs

Providers hand out a fresh list every so often. Replace swaps it and keeps the cooldown of every address that survives:

if err := pool.Replace(newList); err != nil {
    return err
}

Building a second Pool would swap the list too, and would forget that four of those addresses were blocked a minute ago. They'd go straight back into rotation, be refused again, and the pool would relearn the same thing on every refresh.

It also keeps the rotation position, which matters more than it sounds. Sending the next draw back to the head of the list means a refresh on a timer never gets past the first slice of the pool. Measured on 1000 addresses refreshed every 30 minutes with 100 requests in between, over a day:

before   addresses 0-99 carried every request, the other 900 carried none
after    all 1000 carried their share

Round robin itself was never the problem. Over a long-lived pool it is exact: 1000 addresses, 100,000 draws, every address handed out exactly 100 times, and blocking does not distort it either. What went wrong was only ever where the rotation started.

Where the rotation starts

New starts at a random address rather than the first one. A process restarts on every deploy and every crash, and starting at the head each time means those runs draw the same addresses in the same order. Measured over 200 restarts of a 1000-address pool taking 3 requests each:

before   3 addresses carried all 600 requests, 997 carried none
after    the starting point moves, so restarts spread across the pool

Only the starting point is random. The order is still the file order and an address keeps its index, because Stats is worth nothing if a row can't be matched back to a line in your list.

When you want a fixed starting point, say so:

pool.Rotate(0)

That is what the examples and tests here do, and it is also how to carry a position across a restart if you keep one somewhere.

Add appends and skips addresses already present, because a duplicate takes a double share of the traffic under round robin. Remove drops one. Both refuse a batch containing an unparseable address rather than applying part of it.

Seeing what the pool is doing

Len and Healthy give you two numbers, which is not enough to tune anything. Stats returns a snapshot per address:

for _, s := range pool.Stats() {
    log.Println(s)
}
pool#0 gw.example.com:5555  out=412 blocked=3
pool#1 gw.example.com:5555  out=409 blocked=87 cooling 4m20s
pool#2 gw.example.com:5555  out=0 blocked=0 never used

Providers differ enormously in how often their addresses get refused, so a pool mixing two of them looks fine in aggregate while half of it fails. The middle line above is that half.

never used is worth grepping for. An address that has never been handed out on a pool that has been running for hours usually means the rotation is not reaching it.

Two details that are easy to get wrong

Logging a pool whose entries share a host

Credentials have to be stripped from logs. But sticky residential pools give every entry the same host:port and vary only the username, which is what encodes the session. Strip the credentials and a pool of a thousand prints as one line repeated, so a log showing a healthy rotation looks identical to a log showing every request pinned to one address.

Proxy.String() keeps the index for that reason:

pool#0 gw.example.com:5555
pool#1 gw.example.com:5555
pool#2 gw.example.com:5555
The BOM in your proxy list

PowerShell's Out-File and Set-Content write UTF-8 with a BOM by default. Those three invisible bytes attach to the first address only, so gw.example.com becomes a hostname that doesn't resolve, and it presents as that one proxy being dead. Not the sort of thing you'd think to check.

Load strips it, and there's a test for it.

Why the parser checks field count first

A password can legitimately contain an @. So host:port:user:p@ss is checked by field count before anything searches for an @, otherwise it reads as user:pass@host:port and yields a URL pointing at a host that was never in your file.

Field count is unambiguous where a character search isn't. There's a test on this one, because it's the sort of thing a later refactor simplifies straight back into a bug.

What it doesn't do

  • No health checking. Use your own traffic.
  • No per-request retry. That's your control flow, not the pool's.
  • No SOCKS dialer. Proxy.Transport() hands socks5:// URLs to net/http, which supports them. Anything more exotic is yours to build from Proxy.URL.
  • No rotation strategies beyond round robin. If you want one address pinned per long-lived session, use Pool.All() and index it yourself. That's a different strategy, and stacking it on Next() gets you neither.

License

MIT

Documentation

Overview

Package proxypool hands out proxies round-robin and sidelines the ones that got blocked.

It solves the small set of problems that show up every time you run more than one proxy, and that are individually trivial and collectively tedious:

  • providers emit at least four different line formats, and none of them is the URL an HTTP client wants
  • a blocked address should stop being handed out for a while, then come back on its own
  • credentials must never reach a log
  • when every address is cooling down, the caller needs to know that rather than be handed one silently

It is deliberately small: no health checks, no scoring, no background goroutines. A Pool is a picker with a cooldown, and whether an address is healthy is something only the caller's own traffic can answer.

All methods are safe for concurrent use.

Example

The usual loop: take an address, use it, sideline it when the far end complains, and let it come back on its own.

Note what the log lines look like. All three entries share one host:port and differ only in the username, which is how sticky residential pools work, so the index is the only thing that tells them apart once the credentials are stripped.

package main

import (
	"fmt"
	"time"

	"github.com/Mootjelh/proxypool"
)

func main() {
	pool := proxypool.New([]string{
		"http://sess-a:pw@gw.example.com:5555",
		"http://sess-b:pw@gw.example.com:5555",
		"http://sess-c:pw@gw.example.com:5555",
	})

	// New starts at a random address, so that a process restarting does not
	// draw the same ones every time. Pin it here so this example has a fixed
	// answer to print.
	pool.Rotate(0)

	for i := 0; i < 4; i++ {
		p, healthy := pool.Next()
		if !healthy {
			fmt.Println("every address is cooling down; soonest back:", p)
			break
		}
		fmt.Println("request", i, "via", p)

		if i == 1 {
			// Suppose that one came back 429.
			pool.Block(p, time.Hour)
		}
	}

	fmt.Printf("healthy: %d of %d\n", pool.Healthy(), pool.Len())

}
Output:
request 0 via pool#0 gw.example.com:5555
request 1 via pool#1 gw.example.com:5555
request 2 via pool#2 gw.example.com:5555
request 3 via pool#0 gw.example.com:5555
healthy: 2 of 3

Index

Examples

Constants

View Source
const DefaultCooldown = 5 * time.Minute

DefaultCooldown is how long Transport sidelines an address when no Cooldown is set.

Variables

View Source
var ErrPoolEmpty = errors.New("proxypool: pool is empty")

ErrPoolEmpty is returned by Transport.RoundTrip when the pool holds no addresses at all. Every address cooling down is not this error: see Transport.RoundTrip.

Functions

func DefaultBlockOn added in v0.4.0

func DefaultBlockOn(resp *http.Response, err error) bool

DefaultBlockOn is what Transport uses when BlockOn is nil. It blocks the address on 403, on 429, and on any transport error, timeouts included.

What it deliberately leaves alone:

  • 404 and the rest of 4xx. The origin answered a question about a URL. The address delivered the request perfectly well.
  • 5xx. An origin having a bad minute would otherwise sideline the entire pool within seconds, and then there is nothing left to retry on when the origin comes back.
  • a request the caller cancelled. Nothing is known about the address, so penalising it would let an aborted request poison a good one.

A deadline the caller set on the request cannot be told apart here from an address being too slow to answer, and it is counted as a failure. That is the intended way round: on a rotating pool, slow is a kind of broken, and the cooldown expires on its own.

func Mask

func Mask(raw string) string

Mask strips credentials and scheme from a proxy URL so logins never reach a log. It is what Proxy.String uses.

Prefer logging the Proxy itself: on a pool whose entries share a host, a masked URL alone cannot tell them apart.

func Normalize

func Normalize(raw string) (string, error)

Normalize converts the common proxy list formats into a URL an HTTP client accepts:

host:port                    ->  http://host:port
host:port:user:pass          ->  http://user:pass@host:port
user:pass@host:port          ->  http://user:pass@host:port
scheme://...                 ->  unchanged

The third form is what most residential providers emit by default, and it is the one no HTTP client takes.

Example

Normalize accepts the formats providers actually hand out, none of which is the URL an HTTP client wants.

package main

import (
	"fmt"

	"github.com/Mootjelh/proxypool"
)

func main() {
	for _, raw := range []string{
		"1.2.3.4:8080",
		"gw.example.com:7000:user:pass",
		"user:pass@1.2.3.4:8080",
		"socks5://user:pass@host:1080",
	} {
		u, err := proxypool.Normalize(raw)
		if err != nil {
			fmt.Println("error:", err)
			continue
		}
		fmt.Println(u)
	}

}
Output:
http://1.2.3.4:8080
http://user:pass@gw.example.com:7000
http://user:pass@1.2.3.4:8080
socks5://user:pass@host:1080

Types

type Pool

type Pool struct {
	// contains filtered or unexported fields
}

Pool hands out proxies round-robin and sidelines ones that got blocked. The zero Pool is not usable; build one with New or Load.

func Load

func Load(path string) (*Pool, error)

Load reads a proxy list from disk, one entry per line, and normalises each one. Blank lines and lines starting with # are ignored.

An unparseable line is an error naming the line number, rather than a silent skip: a proxy list is configuration, and quietly running on nine of ten addresses is worse than not starting.

func New

func New(urls []string) *Pool

New builds a pool from already-normalised proxy URLs.

An empty slice, or a slice containing one empty string, means "connect directly", so a caller that has no proxies configured can use the same code path as one that does, rather than branching everywhere.

The rotation starts at a random address rather than the first one. A process that restarts, which is every deploy and every crash, would otherwise draw the same addresses in the same order each time: measured over 200 restarts of a 1000-address pool taking 3 requests each, three addresses carried all 600 requests and the other 997 carried none. Use Pool.Rotate when a fixed starting point is wanted.

Only the starting point is random. The order is the file order, and an address keeps its index, because Pool.Stats is worth nothing if a row cannot be matched back to a line in the list.

func (*Pool) Add added in v0.2.0

func (p *Pool) Add(raw ...string) (int, error)

Add appends addresses, normalising each one the way Load does, and reports how many were new.

Addresses already in the pool are skipped rather than appended twice. A duplicate is not harmless: round robin would hand it out twice per cycle, so that address would quietly take a double share of the traffic and reach a rate limit first.

If any address cannot be parsed, nothing is added and the error names it. Adding nine of ten silently is the failure mode Load already refuses.

func (*Pool) All

func (p *Pool) All() []Proxy

All returns every proxy in the pool, in file order, regardless of cooldown.

Use it when you want the list rather than the picker, for instance to pin one address per long-lived session, which is a different rotation strategy from this pool's per-request round robin and should not be built on top of Pool.Next.

func (*Pool) Block

func (p *Pool) Block(pr Proxy, d time.Duration)

Block sidelines a proxy for the given duration after it was throttled.

A shorter Block never shortens a cooldown already in place. Two failures on one address should not leave it less penalised than one.

func (*Pool) Healthy

func (p *Pool) Healthy() int

Healthy is the number of proxies not currently cooling down.

func (*Pool) Len

func (p *Pool) Len() int

Len is the total number of proxies in the pool.

func (*Pool) Next

func (p *Pool) Next() (Proxy, bool)

Next returns the proxy to use for the next request.

The bool reports whether it is currently healthy. When every proxy is cooling down it returns the one that recovers soonest, with false. The caller is better placed than the pool to decide between waiting, proceeding anyway and giving up.

func (*Pool) Remove added in v0.2.0

func (p *Pool) Remove(raw string) bool

Remove drops an address and reports whether it was there. It can be passed in any format Normalize accepts.

It also matches the string verbatim, and that is not belt and braces. New takes its addresses as given, so a pool can hold something Normalize would reject, and matching only the normalised form would leave that entry in the pool with no way to take it out.

Removing the last entry leaves an empty pool, and Pool.Next then reports unhealthy rather than panicking.

func (*Pool) Replace added in v0.2.0

func (p *Pool) Replace(raw []string) error

Replace swaps the whole list, keeping the cooldown of every address that survives the swap.

This is what a provider handing out a fresh list every so often needs. Building a second Pool would do the swap too, and would forget that four of those addresses were blocked a minute ago: they would go straight back into rotation, be refused again, and the pool would relearn the same thing every time the list refreshed.

If any address cannot be parsed, the pool is left untouched.

func (*Pool) Rotate added in v0.5.0

func (p *Pool) Rotate(i int)

Rotate moves the rotation so that the next draw is the address at index i, counting from 0 in file order. Out of range values wrap.

New picks a random starting point, which is right for a long-running process and wrong for a test that wants to know what comes next. This is the way to pin it. It is also how to carry a position across a restart, if the caller keeps one somewhere.

func (*Pool) Stats added in v0.3.0

func (p *Pool) Stats() []Stats

Stats returns a snapshot per address, in file order.

Two counts and a total is not enough to tune a pool. Providers differ enormously in how often their addresses get refused, so a pool mixing two of them looks fine in aggregate while half of it fails. This is the per-address view that shows which half.

func (*Pool) URLs

func (p *Pool) URLs() []string

URLs returns the pool's addresses, in file order.

type Proxy

type Proxy struct {
	// URL is the full proxy URL, credentials included. Pass it to your HTTP
	// client, and do not log it. Use the Proxy itself for that.
	URL string

	// Index is the address's stable position in the pool, counting from 0.
	Index int
}

Proxy is one address from the pool.

func (Proxy) IsDirect

func (p Proxy) IsDirect() bool

IsDirect reports whether this entry means "no proxy, connect directly".

func (Proxy) String

func (p Proxy) String() string

String renders the proxy for logs: credentials stripped, index kept.

The index is not decoration. Rotating residential pools typically give every entry the SAME host:port and vary only the username, which encodes the sticky session. Strip the credentials, which you must, and a pool of a thousand addresses prints as one, so a log that says the rotation is working looks exactly like a log that says it is not.

func (Proxy) Transport

func (p Proxy) Transport() (*http.Transport, error)

Transport returns an *http.Transport routed through this proxy, or a plain one when the entry is direct.

Example

Transport is the bridge to net/http. Build a fresh client per address rather than swapping the proxy on a shared transport: a transport pools connections, and a pooled connection is already through the old proxy.

package main

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

	"github.com/Mootjelh/proxypool"
)

func main() {
	pool := proxypool.New([]string{"http://user:pw@gw.example.com:5555"})

	p, _ := pool.Next()
	transport, err := p.Transport()
	if err != nil {
		fmt.Println(err)
		return
	}

	client := &http.Client{Transport: transport, Timeout: 20 * time.Second}
	_ = client

	fmt.Println("routing through", p)

}
Output:
routing through pool#0 gw.example.com:5555

type Stats added in v0.3.0

type Stats struct {
	Proxy Proxy

	// HandedOut counts every time [Pool.Next] returned this address, including
	// the times it came back with healthy false. The caller receives it either
	// way, and leaving those out would understate the address carrying an
	// exhausted pool.
	HandedOut int

	// Blocked counts every [Pool.Block] that resolved to this address, whether
	// or not it extended a cooldown already in place.
	Blocked int

	// LastUsed is when Next last returned it. Zero means never, which is worth
	// looking for: an address that has never been handed out on a pool that has
	// been running for hours is usually a sign the rotation is not reaching it.
	LastUsed time.Time

	// CoolingUntil is when the current cooldown expires. Zero means healthy.
	CoolingUntil time.Time
}

Stats is what one address has done. It is a copy, so it can be held, sorted and logged without touching the pool again.

func (Stats) String added in v0.3.0

func (s Stats) String() string

type Transport added in v0.4.0

type Transport struct {
	// Pool is where addresses come from. Required.
	Pool *Pool

	// BlockOn decides whether a finished request counts against the address it
	// went through. Nil means [DefaultBlockOn].
	//
	// It is called once per request with exactly what RoundTrip is about to
	// return, so one of resp and err is always nil.
	//
	// It must not read or close resp.Body. That body is the caller's, and a
	// hook that reads it hands them an empty one.
	BlockOn func(resp *http.Response, err error) bool

	// Cooldown is how long a failing address is sidelined. Zero means
	// [DefaultCooldown].
	Cooldown time.Duration

	// Base builds the RoundTripper for one address. Nil means
	// [Proxy.Transport], a plain *http.Transport.
	//
	// This is the seam for a caller who brings their own stack, which is a
	// common reason to be running proxies in the first place: a browser-grade
	// TLS client, a SOCKS dialer, an instrumented transport. It is called once
	// per address and the result is kept.
	Base func(Proxy) (http.RoundTripper, error)
	// contains filtered or unexported fields
}

Transport is an http.RoundTripper that takes an address from a Pool for each request and blocks that address when the request goes wrong.

It exists because the loop around Pool.Next and Pool.Block is the same everywhere: pick, build a client, send, judge the result, report back. Put it behind a RoundTripper and the rotation becomes one line of client setup:

client := &http.Client{Transport: &proxypool.Transport{Pool: pool}}

The zero value is not usable; Pool is required. Safe for concurrent use.

It does not retry. A request that fails comes back to the caller as it is, with the address already sidelined, so the next attempt lands somewhere else. Retrying here would mean guessing how many attempts the caller wants and whether the request body can be replayed, and neither is knowable from inside a RoundTripper.

Example

A Transport puts the rotation behind net/http, so the rest of the program never mentions proxies again. Each request takes the next address, and one that answers 403 or 429, or cannot be reached at all, is sidelined.

package main

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

	"github.com/Mootjelh/proxypool"
)

func main() {
	// Stands in for a proxy whose address the target has had enough of.
	refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusTooManyRequests)
	}))
	defer refusing.Close()

	pool := proxypool.New([]string{refusing.URL})
	client := &http.Client{
		Transport: &proxypool.Transport{Pool: pool, Cooldown: 10 * time.Minute},
		Timeout:   20 * time.Second,
	}

	resp, err := client.Get("http://example.invalid/")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	resp.Body.Close()

	fmt.Println("status", resp.StatusCode)
	fmt.Printf("healthy: %d of %d\n", pool.Healthy(), pool.Len())

}
Output:
status 429
healthy: 0 of 1
Example (BlockOn)

BlockOn decides what counts against an address. Wrap DefaultBlockOn rather than replacing it when the target has a signal of its own on top of the usual ones.

package main

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

	"github.com/Mootjelh/proxypool"
)

func main() {
	// This one answers 503 when it does not want an address, which the default
	// leaves alone because a 503 is normally the origin having a bad minute.
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusServiceUnavailable)
	}))
	defer server.Close()

	pool := proxypool.New([]string{server.URL})
	client := &http.Client{
		Transport: &proxypool.Transport{
			Pool: pool,
			BlockOn: func(resp *http.Response, err error) bool {
				if proxypool.DefaultBlockOn(resp, err) {
					return true
				}
				return resp != nil && resp.StatusCode == http.StatusServiceUnavailable
			},
			Cooldown: 10 * time.Minute,
		},
		Timeout: 20 * time.Second,
	}

	resp, err := client.Get("http://example.invalid/")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	resp.Body.Close()

	fmt.Printf("healthy: %d of %d\n", pool.Healthy(), pool.Len())

}
Output:
healthy: 0 of 1

func (*Transport) CloseIdleConnections added in v0.4.0

func (t *Transport) CloseIdleConnections()

CloseIdleConnections closes idle connections on every address's transport, for the ones that support it. http.Client calls this on its own CloseIdleConnections.

func (*Transport) RoundTrip added in v0.4.0

func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip sends one request through the next address in the pool.

When every address is cooling down it sends anyway, through the one recovering soonest, which is what Pool.Next hands back. The alternatives are worse: sleeping would spend a latency budget the caller never agreed to, and failing would turn a pool that is merely busy into an outage. The caller still finds out, because that request is the one most likely to be refused, and Pool.Stats shows the cooldowns.

An empty pool is different and returns ErrPoolEmpty. There is nothing to send through and no amount of waiting produces one.

http.Client calls RoundTrip once per hop, so a redirect chain is spread over several addresses. That is usually what a rotating pool is for. If a redirect has to stay on one address, follow it yourself.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL