Documentation
¶
Overview ¶
Package proxytron provides scored proxy rotation, discovery, persistence, proxy dialing and real egress IP detection.
Request pools rank routes by expected successful responses per second and maintain independent service scopes, transport cooldowns and challenge cooldowns. Use DefaultConfig and LoadPool to restore a durable pool before discovery. FromURLs constructs an in-memory pool from already validated URLs. A direct route must be explicitly enabled by the application; an empty acquisition means no eligible route, never permission to bypass the pool. Pool.Do adds callback-driven retries with separate operation and route budgets. Pacer shares adaptive request spacing across routes on the same proxy host.
The lease subpackage provides reservations spanning setup and long transfers, per-address concurrency limits and adaptive throughput selection. Neither package knows about application domains, page parsers or browser fingerprints. Applications report their classified outcomes and can supply HTTP adapters. lease.ExitProber probes and claims exits through a reserved session, with caller-selected probe services and an explicit policy for probe outages.
Network activity is explicit: LoadPool can refresh configured feeds, Detector probes only its configured IP-check URL, and NewTransport performs no I/O. Tests use local servers and synthetic endpoints, without public proxy feeds.
Index ¶
- Constants
- Variables
- func CanonicalEndpoint(raw, defaultScheme string) (string, error)
- func Configured(cfg *Config) []string
- func ConnectTunnel(c net.Conn, host string, port int, auth string) (net.Conn, error)
- func IsDirect(s string) bool
- func NewTransport(endpoint string, opts TransportOptions) (*http.Transport, error)
- func ParseEndpoint(raw, defaultScheme string) (string, error)
- func ReadConnectReply(br *bufio.Reader) (code int, status string, err error)
- func Redact(url string) string
- func ResolveLocal(ctx context.Context, host, network string) (string, error)
- func SOCKS4Connect(ctx context.Context, c net.Conn, scheme, user, host string, port int) error
- func SetHTTPProxy(tr *http.Transport, endpoint string, proxyTLS *tls.Config) error
- type AttemptResult
- type Bench
- type Clock
- type Config
- type DeadlineConn
- type Detector
- type Dialer
- type Egress
- type EgressComparison
- type Endpoint
- type HTTPDoer
- type HealthRow
- type Live
- func (l *Live) Close() error
- func (l *Live) Current() *Pool
- func (l *Live) Override(ctx context.Context, urls []string, cfg *Config, marketplaces []string) (*Pool, error)
- func (l *Live) Pool(ctx context.Context, cfg *Config, marketplaces []string, database string) (*Pool, error)
- func (l *Live) RefreshLoop(ctx context.Context, loadCfg func() (*Config, []string, error), ...)
- type Option
- func WithClock(c Clock) Option
- func WithHTTPClient(c *http.Client) Option
- func WithLegacyImport(enabled bool) Option
- func WithLogger(log *slog.Logger) Option
- func WithScopeContext(f func(context.Context, string) context.Context) Option
- func WithSettings(s Settings) Option
- func WithStoreOpener(open StoreOpener) Option
- type Outcome
- type Pacer
- type PacerConfig
- type Pool
- func (p *Pool) Acquire(site string, allowDirect bool, exclude map[string]bool, ...) string
- func (p *Pool) Close() error
- func (p *Pool) Demote(ctx context.Context, site, url, reason string, took time.Duration)
- func (p *Pool) DemoteTransport(ctx context.Context, site, url, reason string, took time.Duration)
- func (p *Pool) DetectEgress(ctx context.Context, d Detector, endpoint string) (Egress, error)
- func (p *Pool) Do(ctx context.Context, scope string, options RequestOptions, ...) error
- func (p *Pool) Egress(endpoint string) (Egress, bool)
- func (p *Pool) Health() []HealthRow
- func (p *Pool) Len() int
- func (p *Pool) NewPass()
- func (p *Pool) PathCount(site string, allowDirect bool) int
- func (p *Pool) Punish(ctx context.Context, site, url, reason string, took time.Duration) time.Duration
- func (p *Pool) Refresh(ctx context.Context, urls []string, marketplaces map[string][]string, ...) (added, removed int)
- func (p *Pool) RefusedCooling(site string, allowDirect bool) bool
- func (p *Pool) ReportOK(ctx context.Context, site, url string, took time.Duration)
- func (p *Pool) Save(ctx context.Context) error
- func (p *Pool) SiteHealth(site string) []SiteHealth
- func (p *Pool) WaitFor(site string, allowDirect bool, exclude map[string]bool) (time.Duration, bool)
- type ProbeResponseError
- type RequestOptions
- type Settings
- type SiteBrief
- type SiteHealth
- type SleepClock
- type StoreOpener
- type TransportOptions
Examples ¶
Constants ¶
const ( SelectionThroughput = "throughput" SelectionRoundRobin = "round_robin" )
Selection policies for request pools.
const Direct = "direct"
Direct is a pool member meaning "send this one straight out, no proxy". Listing it alongside real endpoints is what makes alternating between a tunnel and the machine's own address a rotation rather than a switch, and it earns the same health handling as any endpoint: if the home address is the one being walled, it demotes and the tunnel carries the run, and vice versa.
Variables ¶
var ( // ErrNoRoute means the pool has no route the scope is allowed to use. ErrNoRoute = errors.New("proxytron: no eligible route") // ErrRouteWait means eligible routes are cooling beyond the remaining wait budget. ErrRouteWait = errors.New("proxytron: route wait budget exhausted") // ErrAttemptsExhausted means one of the independent retry budgets was spent. ErrAttemptsExhausted = errors.New("proxytron: attempt budget exhausted") )
Errors returned by Pool.Do describe why another attempt could not be made. They may wrap the last attempt's error as well; use errors.Is to inspect both.
var ErrNoResponse = errors.New("connection closed before a response")
ErrNoResponse indicates that a proxy closed before sending a status line.
var ErrNoScopes = errors.New("a {country} proxy refresh URL requires marketplace codes")
ErrNoScopes is a `{country}` refresh_url with no scope codes to expand it with: no [proxies] scopes and none scanned.
var ErrTunnelRefused = errors.New("proxy refused the tunnel")
ErrTunnelRefused indicates that an HTTP proxy refused CONNECT.
Functions ¶
func CanonicalEndpoint ¶
CanonicalEndpoint also lowercases host names and normalizes URL escaping. Applications migrating existing records should retain their original key convention; ParseEndpoint preserves authority spelling instead.
func Configured ¶
Configured is the endpoints cfg describes, redacted — what the pool *would* hold.
Lets a status endpoint answer before the first scan has built the pool, without installing anything as a side effect.
func ConnectTunnel ¶
connectTunnel asks an http(s) proxy for a tunnel: the CONNECT line, Proxy-Authorization when the proxy URL carries credentials, then Host. Only a 200 reply opens the tunnel; any other is ErrTunnelRefused.
func IsDirect ¶
IsDirect reports whether s names Direct as a config line or a request body may write it: in any case, surrounding space ignored.
func NewTransport ¶
func NewTransport(endpoint string, opts TransportOptions) (*http.Transport, error)
NewTransport returns an isolated HTTP transport. Explicit routes ignore HTTP_PROXY, HTTPS_PROXY and NO_PROXY. HTTP(S) proxies forward HTTP requests and use the tolerant CONNECT implementation for HTTPS requests.
func ParseEndpoint ¶
ParseEndpoint validates a proxy URL or host:port. It requires an explicit port, supports HTTP(S), SOCKS4(a), SOCKS5(h), and recognizes Direct. SOCKS5 entries are normalized to SOCKS5h for remote destination DNS. Credentials and authority spelling are retained as stable inventory keys.
func ReadConnectReply ¶
readConnectReply reads a proxy's reply to CONNECT to the end of its head: the status line, whose code and status ("407 Proxy Authentication Required") it returns, then the header lines, skipped unread. It is lenient on purpose. Free proxies answer with header lines a strict reader such as http.ReadResponse refuses, and refusing them would bench routes that carry the tunnel fine; so only a status line that is not "HTTP/... <code> [reason]" fails, besides the limits (maxLine, maxHeaders). A connection that closes inside the header lines ends them.
func Redact ¶
Redact never lets proxy credentials into a log line or a page, if the endpoint carries any.
Example ¶
package main
import (
"fmt"
"github.com/JohanLindvall/proxytron"
)
func main() {
fmt.Println(proxytron.Redact("http://user:secret@proxy.example.test:8080"))
}
Output: http://***@proxy.example.test:8080
func ResolveLocal ¶
resolveLocal resolves host on this machine, for a SOCKS proxy that is given an address (socks5, socks4): the first address, or the first IPv4 one for SOCKS4.
func SOCKS4Connect ¶
socks4Connect asks a SOCKS4 proxy to connect: by IPv4 address, or for socks4a by name (address 0.0.0.1 and the host after the user id).
func SetHTTPProxy ¶
SetHTTPProxy configures an unused net/http transport in place, preserving its destination TLS verification, dialer, CONNECT hooks and other settings. HTTP(S) and SOCKS5(h) use net/http's native proxy support and remote DNS. Direct clears proxy selection. Explicit routes ignore environment bypasses.
proxyTLS applies only to an HTTPS proxy's outer connection, even for forwarded HTTP requests. Nil verifies that certificate normally. Clone a transport before configuring it; changing a transport already serving requests is unsafe. Use NewTransport or Dialer when SOCKS4 or tolerant CONNECT replies are needed.
Types ¶
type AttemptResult ¶ added in v0.2.0
AttemptResult separates scoring, retry policy and the error returned to the caller. Retry requests another attempt. RouteFailure charges a retry to the route budget instead of the operation budget, without applying Backoff. This lets applications decide whether a refusal or challenge costs the operation. Err may be nil for a classified response; exhausted retries still return an error. The zero value ends the operation successfully without scoring it.
type Bench ¶
type Bench string
Bench is why a route is out of selection on a site; "" when it is not.
const ( // BenchDead is a feed entry that has never served a page and failed to // connect: out everywhere for a day. BenchDead Bench = "dead" // BenchBroken is any other route that failed to connect: out everywhere // for minutes, doubling. BenchBroken Bench = "broken" // BenchWAF is a route the site's WAF challenged. BenchWAF Bench = "waf" // BenchRefused is a route the site refused. BenchRefused Bench = "refused" )
The benches, spelled as /api/proxies and deals.json carry them.
type Clock ¶
Clock is where a Pool reads the time: every cooldown, bench and stored timestamp follows it.
type Config ¶
type Config struct {
// Revision lets caller-specific configuration changes invalidate a live override.
Revision string
Endpoints []string
FeedURL string
DefaultScheme string
Scopes []string
RefreshInterval time.Duration
MinReady int
Settings
}
Config configures static routes and an optional discovery feed. Scope names are caller-defined. A {country} feed template expands scopes in uppercase. Start with DefaultConfig and change the fields your application needs. Static endpoints retain their spelling for compatibility with existing stored keys. Call ParseEndpoint at the application's configuration boundary to validate new routes. Discovery feeds and the provided transports always validate routes.
func DefaultConfig ¶
func DefaultConfig() Config
DefaultConfig returns conservative discovery and health defaults.
type DeadlineConn ¶
DeadlineConn gives each Read and each Write on Conn a deadline of its own, Timeout from the moment the call starts. It bounds every operation rather than the exchange, as a socket timeout does: a peer that keeps sending is never cut off, and one that falls silent for Timeout is. Wrapped around a *tls.Conn, a Read or Write is one TLS operation, so its deadline covers every socket read the record takes, and a peer dripping a record a byte at a time still times out. A zero or negative Timeout sets no deadline.
The proxy feed downloads use it, under net/http. This package's own connections change their timeout as a request moves from connecting to reading and probe idle connections, and keep a wrapper of their own for that.
type Detector ¶
type Detector struct {
URL string
Timeout time.Duration
Client func(endpoint string) (HTTPDoer, error)
}
Detector checks egress via a caller-selected URL that returns a plain IP or a JSON object with an "ip" or "origin" field. No external service is contacted until Detect is called. Client optionally adapts an application's own stack. It must honor endpoint (including Direct), and must not fall back to direct.
type Dialer ¶
type Dialer struct {
ProxyURL string
Timeout time.Duration
ProxyTLSConfig *tls.Config
Dial func(context.Context, string, string) (net.Conn, error)
}
Dialer opens explicit proxy routes, without environment proxy settings or fallback to a direct connection. Zero Timeout uses 20 seconds. Destination TLS belongs to the caller; ProxyTLSConfig configures only the outer hop.
func (Dialer) DialContext ¶
func (d Dialer) DialContext(ctx context.Context, network, address string) (conn net.Conn, err error)
DialContext opens a TCP stream through HTTP CONNECT or SOCKS. An empty ProxyURL or Direct selects a direct connection. SOCKS5 resolves locally; SOCKS5h and SOCKS4a resolve at the proxy. Context cancellation closes a connection still negotiating, and successful connections have no deadlines.
type Egress ¶
Egress records the actual address observed by an IP-check service, which may differ from a proxy's listening address or DNS name. It is a point-in-time measurement: rotating gateways can change addresses between requests.
type EgressComparison ¶
type EgressComparison struct{ Direct, Proxy Egress }
EgressComparison compares a proxy's observed address with the local route. A different IP alone does not prove anonymity or absence of identifying headers.
func (EgressComparison) SameIP ¶
func (c EgressComparison) SameIP() bool
SameIP reports that the proxy and direct connection used the same exit IP.
type Endpoint ¶
type Endpoint struct {
URL string
Metadata json.RawMessage
}
Endpoint is a validated feed entry. Metadata is retained for diagnostics; scores supplied by a feed never influence the pool's learned scores.
func ParseFeed ¶
ParseFeed accepts text lines, JSON URL arrays and Proxifly-style records. country optionally filters record metadata. Malformed nonempty feeds fail; an explicit empty feed succeeds. A feed can never enable direct egress.
Example ¶
package main
import (
"fmt"
"github.com/JohanLindvall/proxytron"
)
func main() {
entries, err := proxytron.ParseFeed([]byte(`[
{"proxy":"socks5://proxy.example.test:1080","geolocation":{"country":"SE"}},
"direct"
]`), "http", "se")
if err != nil {
panic(err)
}
fmt.Println(entries[0].URL)
}
Output: socks5h://proxy.example.test:1080
type HealthRow ¶
type HealthRow struct {
URL string `json:"url"` // redacted
Scopes []string `json:"marketplaces"`
Latency *float64 `json:"latency"`
Refusal *float64 `json:"refusal"`
TransportFails int `json:"transport_fails"`
TransportFailure float64 `json:"transport_failure"`
Served int `json:"served"`
Cooling float64 `json:"cooling"`
// Keyed by scope code (never ""). encoding/json writes it in code
// order; iterate it in that order with slices.Sorted(maps.Keys(row.Sites)).
Sites map[string]SiteBrief `json:"sites"`
}
HealthRow is one row of Pool.Health: the endpoint-wide picture plus a per-site breakdown.
type Live ¶
type Live struct {
// contains filtered or unexported fields
}
Live is the process's proxy pool, kept from one pass to the next: rebuilding it per pass would discard cooldowns, rotation positions and measured health. cli owns one, hands it to every pass and to the server, and closes it on the way out.
Pool brings it into line with the config at the start of every pass, and RefreshLoop between passes; a config change goes through a refresh, which keeps the history of the endpoints that survive it. Override replaces the list from the API until the config changes. Current reads it.
Pool, Override and Close take turns, and Pool keeps its turn across the feed downloads it makes, so a refresh in progress finishes before the next starts. Current never waits. The Live's lock is taken before the pool's, never after.
func NewLive ¶
NewLive returns a Live with no pool yet. opts are the options every pool it builds is given: WithClock, WithHTTPClient.
func (*Live) Close ¶
Close closes the live pool's database, if one is attached, and drops the pool; a later Pool builds a new one. It waits for a refresh in progress. Closing a nil Live does nothing.
func (*Live) Current ¶
Current is the live pool as it stands, nil before the first Pool or Override (and for a nil Live). It never waits for a refresh in progress: it is what the status endpoints read.
func (*Live) Override ¶
func (l *Live) Override(ctx context.Context, urls []string, cfg *Config, marketplaces []string) (*Pool, error)
Override replaces the live pool's endpoints with urls right now, from outside a pass, and returns the pool. This is what backs POST /api/proxies: the list changes mid-flight without a restart, and anything still on it keeps its health, because the pool is refreshed rather than rebuilt.
cfg is the config in force, so that the override sticks: Pool reconciles with the config at the start of every pass, and without knowing that cfg is what the override was made under, the very next pass would read the override as a config change and undo it. With it, the override holds until someone actually edits [proxies], and that edit wins. cfg may be nil. Explicit URL spelling is preserved, as in Config.Endpoints.
func (*Live) Pool ¶
func (l *Live) Pool(ctx context.Context, cfg *Config, marketplaces []string, database string) (*Pool, error)
Pool is the live pool brought into line with cfg (nil when [proxies] is not enabled, which closes and drops any pool there was), with database ("" = none) attached.
Country URL templates use uppercase scope codes, and each source keeps its last good list if a later download fails. Health survives list updates. A different database path than the attached one reopens the pool from that database; feeds are downloaded only when due. Once ctx has ended nothing is built or refreshed, and ctx's error is returned.
func (*Live) RefreshLoop ¶
func (l *Live) RefreshLoop(ctx context.Context, loadCfg func() (*Config, []string, error), database string, logs ...*slog.Logger)
RefreshLoop brings the pool into line once a minute until ctx ends, so feeds refresh even between scans: each tick re-reads the config with loadCfg, so an edit applies without a restart, and downloads only the feeds that are due. database "" is none. A tick that fails, or panics, is logged and the next one runs.
type Option ¶
type Option func(*Pool)
Option configures a Pool built by New, FromURLs or LoadPool, or every pool a Live builds (NewLive).
func WithClock ¶
WithClock makes the pool read the time from c instead of the system clock; nil keeps the system clock.
func WithHTTPClient ¶
WithHTTPClient makes the pool download its feeds through c instead of a client of its own (newFeedClient); nil keeps that. Tests hand it a client that answers from memory.
func WithLegacyImport ¶
WithLegacyImport enables importing proxy-memory.json beside the store once. Enable only when migrating an application that owns that file.
func WithLogger ¶
WithLogger supplies the logger for outcome and persistence diagnostics.
func WithScopeContext ¶
WithScopeContext allows an application to retain its contextual log metadata. The function must not call back into the pool.
func WithSettings ¶
WithSettings replaces the pool's settings, which default to those of an empty [proxies] section. LoadPool's pool takes them from its config regardless.
func WithStoreOpener ¶
func WithStoreOpener(open StoreOpener) Option
WithStoreOpener replaces the default Bolt store, for example with an adapter to an application's existing persistent database.
type Outcome ¶ added in v0.2.0
type Outcome uint8
Outcome is an application's classification of an attempt for route scoring. It is independent of whether the application wants to retry the operation.
const ( // Unreported leaves route health unchanged, for example for a local error. Unreported Outcome = iota // Succeeded records a usable response. Succeeded // Refused records a refusal limited to the request's scope. Refused // TransportFailed records an endpoint-wide connection failure. TransportFailed // Challenged records a challenge limited to the request's scope. Challenged )
type Pacer ¶ added in v0.2.0
type Pacer struct {
// contains filtered or unexported fields
}
Pacer spaces request starts and adapts to caller-reported refusals. Share a root Pacer across workers and use ForRoute for each attempt. It is safe for concurrent use, owns no goroutines or clients, and needs no cleanup.
Example ¶
package main
import (
"fmt"
"time"
"github.com/JohanLindvall/proxytron"
)
func main() {
pacer := proxytron.NewPacer(proxytron.PacerConfig{MinInterval: time.Second}, nil)
httpRoute := pacer.ForRoute("http://proxy.example.test:8080")
socksRoute := pacer.ForRoute("socks5h://proxy.example.test:1080")
fmt.Println(httpRoute == socksRoute)
// Call httpRoute.Wait(ctx) before sending and Refused or OK afterward.
Output:
func NewPacer ¶ added in v0.2.0
func NewPacer(config PacerConfig, clock SleepClock) *Pacer
NewPacer constructs a direct-route pacer and its shared registry of proxy hosts. A nil clock uses real time. Settings are copied at construction.
func (*Pacer) Delay ¶ added in v0.2.0
Delay estimates the wait for this host without taking a slot. It never waits for a sleeping request and can be used in Pool.Acquire's wait callback.
func (*Pacer) ForRoute ¶ added in v0.2.0
ForRoute returns the pacer shared by endpoint's host, ignoring credentials, protocol and port. Empty and Direct select the root pacer; this is only a pacing key and never authorizes direct routing. Invalid URLs use their full string as a private key and remain subject to transport validation.
func (*Pacer) OK ¶ added in v0.2.0
func (p *Pacer) OK()
OK eases this host's request floor toward its configured minimum.
func (*Pacer) Penalty ¶ added in v0.2.0
Penalty returns this host's current multiplier on MinInterval. It starts at one and remains observable when pacing is disabled, for health diagnostics.
Example ¶
package main
import (
"fmt"
"github.com/JohanLindvall/proxytron"
)
func main() {
pacer := proxytron.NewPacer(proxytron.PacerConfig{SlowFactor: 2, Decay: 0.5}, nil)
route := pacer.ForRoute("http://proxy.example.test:8080")
route.Refused()
fmt.Println(route.Penalty())
route.OK()
fmt.Println(route.Penalty())
}
Output: 2 1
type PacerConfig ¶ added in v0.2.0
type PacerConfig struct {
MinInterval time.Duration
SlowFactor float64
MaxPenalty float64
Decay float64
}
PacerConfig controls the request floor for each proxy host. MinInterval <= 0 disables pacing. Refusals multiply the floor by SlowFactor, up to MaxPenalty; successes multiply the penalty by Decay, never below one. Invalid or zero factors default to 2, 8 and 0.9 respectively. A factor of one disables that adjustment. Pacing is shared across scopes using the same host.
type Pool ¶
type Pool struct {
// contains filtered or unexported fields
}
Pool is a pool of proxy URLs, shared across scope goroutines.
Acquire picks the available endpoint expected to deliver valid responses fastest (or rotates in list order with selection = "round_robin"); Demote, DemoteTransport and Punish push a failing one into a growing cooldown; Refresh swaps in a new list without losing the health state of endpoints that carry over. Country feeds are restricted to their scope. "" from Acquire means no eligible route is available. Direct egress requires an explicitly configured Direct member and the caller's permission.
Build one with New, FromURLs or LoadPool. A nil *Pool is an empty one to Len.
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/JohanLindvall/proxytron"
)
func main() {
pool := proxytron.FromURLs([]string{"http://proxy.example.test:8080"})
defer pool.Close()
route := pool.Acquire("catalog", false, nil, nil)
// Send the request using route, then classify the response in the application.
pool.ReportOK(context.Background(), "catalog", route, 200*time.Millisecond)
fmt.Println(pool.SiteHealth("catalog")[0].Served)
}
Output: 1
func LoadPool ¶
func LoadPool(ctx context.Context, cfg *Config, marketplaces []string, database string, opts ...Option) (*Pool, error)
LoadPool builds a pool from the database first (database "" = none), replenishing or refreshing feeds if due. Its settings are cfg's (SettingsFrom); opts may give it a Clock and the HTTP client its feeds download through.
Returns nil when nothing is configured. An enabled but empty pool is kept so a failed source can be retried on schedule. Static endpoints, if any, remain usable when a source fails.
func (*Pool) Acquire ¶
func (p *Pool) Acquire(site string, allowDirect bool, exclude map[string]bool, wait func(url string) time.Duration) string
Acquire is the route for this scope's next attempt, or "" when every route it may use is benched.
Cooling routes and forbidden direct/foreign routes are never handed out, and a retry prefers a route it has not tried for this page (exclude). Among the rest, the default selection = "throughput" maximises valid responses per second. A scope's fetcher sends one request at a time, so its throughput is what an attempt yields over what it costs, and the best route is the one with the highest P(valid) / E[seconds] (a renewal-reward argument: the long-run rate of a sequence of attempts is E[reward] / E[time] per attempt).
P(valid) is a Beta posterior over this route's recent attempts on this site, and seconds is its mean attempt time whatever the outcome, plus what wait(url) says its host's pacing floor would add right now. wait may be nil; it runs under the pool's lock, so it must not call back into the pool. Each candidate's P(valid) is drawn from its posterior rather than taken at the mean (Thompson sampling), so a route that looks slightly worse still gets the occasional turn its uncertainty earns, and a route that starts failing loses its share within a few attempts. Untried routes are interchangeable until tried: one draw from the site's prior stands for all of them, and the next one in list order is the one tried. Drawing once per untried route instead would let four hundred of them, three in four dead, outbid a route known to work by sheer number of lottery tickets.
selection = "round_robin" restores the rotation this replaced: each scope keeps its own position in the list, the last URL rather than an index, so a feed refresh that reorders entries does not reset it.
func (*Pool) Demote ¶
Demote sends a route that site refused to cooldown on that site alone, after an attempt that took took. A url not in the pool is ignored.
Refusal is a per-domain verdict: it cools the route for that site and leaves the other four using it, since letting `se` evict an endpoint everywhere would throw away a path that four other sites are being served by perfectly well. The cooldown grows geometrically from Settings.BaseCooldown (a minute) per refusal in a row, up to half an hour. A failed connection is DemoteTransport's and an WAF challenge Punish's.
func (*Pool) DemoteTransport ¶
DemoteTransport benches a route that did not carry the request at all — a dead tunnel, a refused connection — after an attempt for site that took took. That is endpoint-wide, so it cools the route for every site, but the record is kept until age-based cleanup. A url not in the pool is ignored.
A failed connection is punished far harder than a refusal, because a rotation gives every endpoint off its bench a full turn: on c016fcf's first pass one destination spent 429 of its 500 requests and another 396 on proxies that never connected, because each came back after a minute. A feed entry that has never served a page is a dead entry and sits out Settings.DeadCooldown (a day) at once. One that has served, or a static endpoint or Direct (the operator's own routes, not a list's guesses), sits out Settings.BrokenCooldown (ten minutes), doubling per failure in a row up to the same day.
func (*Pool) DetectEgress ¶
DetectEgress probes a configured endpoint without holding the pool lock and persists the resulting identity with its scores. Failures leave prior data intact.
func (*Pool) Do ¶ added in v0.2.0
func (p *Pool) Do(ctx context.Context, scope string, options RequestOptions, attempt func(context.Context, string) AttemptResult) error
Do rotates routes, waits for cooldowns, paces request starts and scores the outcomes returned by attempt. The callback owns request construction, session state, response classification and body cleanup. Close or consume bodies before returning; a result can be retained in the callback's closure. Only retry operations the application can safely replay.
Do invokes callbacks synchronously, outside pool locks, and owns no clients or goroutines. Cancellation of ctx or a context.Canceled attempt is never scored. An empty pool never permits direct egress. Failed routes are avoided while alternatives are ready, then may be reused after their cooldowns, like Acquire.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/JohanLindvall/proxytron"
)
func main() {
pool := proxytron.FromURLs([]string{"http://first.example.test:8080", "http://second.example.test:8080"})
defer pool.Close()
attempts := 0
var body string
err := pool.Do(context.Background(), "catalog", proxytron.RequestOptions{Attempts: 1},
func(ctx context.Context, endpoint string) proxytron.AttemptResult {
attempts++
// An application sends and classifies the request here. These
// synthetic results demonstrate the two independent retry budgets.
if endpoint == "http://first.example.test:8080" {
return proxytron.AttemptResult{Outcome: proxytron.TransportFailed,
Err: errors.New("connection failed"), Retry: true, RouteFailure: true}
}
body = "response data"
return proxytron.AttemptResult{Outcome: proxytron.Succeeded}
})
fmt.Println(body, attempts, err)
}
Output: response data 2 <nil>
func (*Pool) Health ¶
Health is one row per endpoint: the endpoint-wide picture plus a per-site breakdown.
The breakdown is the point: a single blended refusal rate reads as "this endpoint is half-refused" when the truth is "one scope refuses everything and the other four are clean".
func (*Pool) Len ¶
Len is the number of routes the pool hands out; 0 for a nil pool, so `pool.Len() > 0` is the test for a pool worth asking.
func (*Pool) NewPass ¶
func (p *Pool) NewPass()
NewPass starts every site's count of refusals in a row afresh.
That count doubles a route's cooldown per refusal and resets only on a served page, and the pool outlives the pass, so a pass whose warm-up was refused twice handed the next one, two hours later, a count of two. Its first 503 then benched the tunnel for 240 s instead of 60, the warm-up's second chance one minute later found no route, and the pass was lost as "refused" without a second request: one destination on 2026-10-03, 10-04 and 10-05 alike. Two hours is four times the longest cooldown, so the count carries nothing a new pass should inherit. What does carry over is kept: a cooldown still running is still honoured, and the refusal rate that describes the endpoints is evidence, not a streak.
func (*Pool) Punish ¶
func (p *Pool) Punish(ctx context.Context, site, url, reason string, took time.Duration) time.Duration
Punish benches an endpoint on one scope after that site's WAF challenged it, in an attempt that took took. It returns the bench, 0 if url is not in the pool.
A challenge is the one verdict that is about the route rather than the request or the site: the WAF looked at the address and asked it to prove it is a browser, which it cannot. On 2026-10-09 seven routes in one destination's feed drew it on most requests, pass after pass, while the route carrying all 151 of a working destination's pages drew none — and because a challenge page is small, it came back fast and had been *raising* those routes' share. So the bench is hours rather than the minute a refusal earns (Settings.ChallengeCooldown, doubling per challenge in a row up to a day), the challenge counts in the site's refusal rate so its history remains visible once it is back, and only a page it actually serves clears the doubling. Per site: another scope's WAF has not judged it. Nothing is dropped — it can still prove it came back.
func (*Pool) Refresh ¶
func (p *Pool) Refresh(ctx context.Context, urls []string, marketplaces map[string][]string, feed []string) (added, removed int)
Refresh replaces the endpoint list, preserving health state for carry-overs. scopes restricts endpoints (url -> codes; nil = every endpoint shared) and feed names the ones that came from a refresh feed.
Returns (added, removed). An endpoint present before and after keeps its fail count and cooldown, so a periodic refresh from the same source does not reset the cooldown on something that has been failing all along.
func (*Pool) RefusedCooling ¶
RefusedCooling reports whether a route site may use is cooling right now because site refused it (Demote), rather than benched for a failed connection (DemoteTransport) or an WAF challenge (Punish): the difference the proxy list draws between "kyls ned" and "spärrad … (WAF)" (benchOf, whose endpoint-wide benches do not hide a refusal here). A site that runs out of routes while one of them is cooling for its refusal has been refused into stopping, whatever benched the rest; see fetch's HaltRefused. A route on a single-route site that WAF challenged was demoted, not punished, and counts.
func (*Pool) ReportOK ¶
ReportOK records that a real page came back through url for site, in an attempt that took took (the whole request, body read included). It clears the failure state for this site and updates the latency measurement. Transport health is cleared too: the endpoint demonstrably carries traffic. A url not in the pool is ignored.
The outcome methods log, and persist, under ctx tagged with site.
func (*Pool) Save ¶
Save checkpoints all state and reports errors synchronously. Outcome methods already persist individual records; Save also retries any previously failed save.
func (*Pool) SiteHealth ¶
func (p *Pool) SiteHealth(site string) []SiteHealth
SiteHealth is one row per endpoint site may use, with that scope's own numbers.
func (*Pool) WaitFor ¶
func (p *Pool) WaitFor(site string, allowDirect bool, exclude map[string]bool) (time.Duration, bool)
WaitFor is how long until an endpoint site may use comes off cooldown: 0 when one is usable now; ok is false when the pool holds none it may use at all.
This lets callers wait for a route instead of spending attempts that send nothing; Pool.Do uses it for cooldown waits. Both cooldowns count, the endpoint-wide one (transport) and the site's own (refusal), since either keeps Acquire from handing the endpoint out.
type ProbeResponseError ¶ added in v0.2.0
ProbeResponseError means an IP-check service answered but did not supply a usable address. It can be retried against another caller-configured service; by itself it is not evidence of a broken route. Diagnostics omit reply bodies and URLs, which may contain credentials. Status is zero for an invalid body.
func (*ProbeResponseError) Error ¶ added in v0.2.0
func (e *ProbeResponseError) Error() string
Error describes the rejected response without including its contents.
type RequestOptions ¶ added in v0.2.0
type RequestOptions struct {
AllowDirect bool // Direct must also be a configured pool member.
Attempts int
RouteAttempts int
// MaxRouteWait bounds the total time spent waiting for route cooldowns.
// Zero disables those waits. Pacing, Backoff and I/O are bounded by ctx.
MaxRouteWait time.Duration
Pacer *Pacer
// Backoff receives the number of ordinary failed attempts, starting at one.
// Nil retries immediately. It is never called for a route failure.
Backoff func(failures int) time.Duration
// Sleep defaults to a context-aware timer. Tests using WithClock should
// supply a sleep function that advances the same clock.
Sleep func(context.Context, time.Duration) error
}
RequestOptions controls one Pool.Do call. Attempts limits ordinary failed attempts and defaults to one. RouteAttempts limits failures charged to routes and defaults to max(Attempts, the scope's configured route count).
type Settings ¶
type Settings struct {
// BaseCooldown benches a route a site refused, on that site, doubling
// per refusal in a row up to half an hour. See Pool.Demote.
BaseCooldown time.Duration
// ChallengeCooldown benches a route a site's WAF challenged, on that
// site, doubling per challenge in a row up to a day. See Pool.Punish.
ChallengeCooldown time.Duration
// A failed connection benches the route everywhere. A feed entry that
// has never served a page sits out DeadCooldown at once; anything else
// sits out BrokenCooldown, doubling per failure in a row up to
// DeadCooldown. See Pool.DemoteTransport.
BrokenCooldown time.Duration
DeadCooldown time.Duration
// Selection is SelectionThroughput (see Pool.Acquire) or
// SelectionRoundRobin, the rotation it replaced: kept so the two
// can be compared from the watchlist.
Selection string
}
Settings are a Pool's tunables, as a [proxies] section sets them (SettingsFrom).
func SettingsFrom ¶
SettingsFrom is the settings cfg configures. A pool reconciled with a config (LoadPool, and every refresh after) takes its settings from there, so an edit of the watchlist applies without a restart.
type SiteBrief ¶
type SiteBrief struct {
Latency *float64 `json:"latency"`
Refusal *float64 `json:"refusal"`
Fails int `json:"fails"`
Challenges int `json:"challenges"`
Cooling float64 `json:"cooling"`
}
SiteBrief is one site's numbers inside a HealthRow.
type SiteHealth ¶
type SiteHealth struct {
URL string `json:"url"` // redacted
Site string `json:"site"`
Latency *float64 `json:"latency"` // nil until measured
Refusal *float64 `json:"refusal"` // nil until measured
Fails int `json:"fails"`
Challenges int `json:"challenges"`
TransportFailure float64 `json:"transport_failure"`
Served int `json:"served"`
// Why it is out, if it is: BenchDead or BenchBroken (a failed
// connection, endpoint-wide), BenchWAF (an WAF challenge) or
// BenchRefused (this site); "" when it is not benched.
Bench Bench `json:"bench"`
Cooling float64 `json:"cooling"`
// What throughput selection believes: the share of attempts that come
// back valid, the seconds one takes, and so valid responses per minute.
Tries int `json:"tries"`
Valid *float64 `json:"valid"` // nil when never tried
AttemptSeconds *float64 `json:"attempt_seconds"` // nil until an attempt is timed
ValidPerMinute *float64 `json:"valid_per_minute"` // nil when never tried
}
SiteHealth is one row of Pool.SiteHealth: that scope's own numbers for one endpoint — what the report reads as a site's proxy health.
type SleepClock ¶ added in v0.2.0
SleepClock supplies time and cancelable waits. A test clock should advance Now when Sleep succeeds. Implementations must be safe for concurrent use.
type StoreOpener ¶
StoreOpener opens an owned persistence store. The pool closes it on Close.
type TransportOptions ¶
type TransportOptions struct {
Timeout time.Duration
ProxyTLSConfig *tls.Config
TLSClientConfig *tls.Config
}
TransportOptions keeps proxy and destination TLS policy separate. Certificate verification is enabled by default on both hops. Insecure proxy TLS, when explicitly configured, never disables destination verification.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
rotate
command
Command rotate fetches a URL using a persistent request pool and shared pacing.
|
Command rotate fetches a URL using a persistent request pool and shared pacing. |
|
transfer
command
Command transfer confirms a reserved route's exit before streaming a response.
|
Command transfer confirms a reserved route's exit before streaming a response. |
|
internal
|
|
|
Package lease provides persistent, address-limited routes for long transfers.
|
Package lease provides persistent, address-limited routes for long transfers. |
|
Package store defines the request pool's persistence boundary and provides a transactional Bolt implementation.
|
Package store defines the request pool's persistence boundary and provides a transactional Bolt implementation. |