Golang HTTP Proxy: How to Use a Proxy With net/http

Published:

15 minute read

Acar Diveroli
Written by: Acar Diveroli
Three stops on a measured route: a net/http client, a big blue proxy stop with a tunnel glyph, and a target answering 200 OK

Your Go service has to send its requests through a proxy: an exit in another country, a fixed IP a partner has whitelisted, or a pool so that one address does not carry every request. Go needs no extra package for this; net/http has proxy support built in, SOCKS5 and the standard environment variables included. The surprises are in the defaults: http.Get already reads HTTPS_PROXY, localhost is never proxied, the variables are read once per process, and an HTTP/2 connection can quietly skip the proxy function you wrote for rotation.

This guide covers http.ProxyURL, ProxyFromEnvironment and the NO_PROXY rules, the Proxy-Authorization header, SOCKS5 in both of Go's forms, a different proxy per request, timeouts, retries, the exact error texts and a complete fetcher. Every snippet ran on Go 1.26.6 (golang.org/x/net v0.62.0) against a local HTTP proxy that requires user:pass and a local SOCKS5 server; outputs and log lines are real, only the proxy host was replaced by pr.proxynet.io:8000. The Node.js side of the same job is in Using a Proxy in Node.js.

How does net/http decide which proxy to use?

Every request goes through an http.Transport, whose Proxy field is a function that receives the request and returns the proxy URL, or nil for a direct connection. The URL's scheme picks the proxy type: the net/http Transport documentation lists http, https, socks5 and socks5h, an empty scheme means http, and credentials in the URL become a Proxy-Authorization header. http.DefaultTransport, behind http.Get, sets Proxy to http.ProxyFromEnvironment; a Transport you build yourself uses no proxy until you set one.

Then, depending on the target:

  1. The Transport dials the proxy, not the target; a missing port falls back to 80 for http, 443 for https and 1080 for SOCKS.
  2. Plain http:// target. The request goes to the proxy with the full URL in the request line, plus Proxy-Authorization on every request.
  3. https:// target. Go sends CONNECT host:443 with the header once; after the proxy's 200, the TLS handshake runs inside the tunnel and the proxy sees only host and port. The CONNECT method is defined in RFC 9110.
  4. socks5:// proxy. Go runs the SOCKS handshake, logs in if the URL has credentials, and sends the target's host name, so the proxy resolves DNS.
  5. The connection is pooled for the next request to the same target through the same proxy, over HTTP/1.1 keep-alive or HTTP/2, and a reused connection does not call Proxy again.

How do you set a proxy with http.ProxyURL?

http.ProxyURL wraps a fixed URL in a function of the right shape. Parse the address once, put it in a Transport, and give the Transport to a Client that also carries a timeout:

go
package main

import (
	"cmp"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"time"
)

func main() {
	// The endpoint builder in the panel prints this string. Keep it in an environment variable, not in git.
	raw := cmp.Or(os.Getenv("PROXY_URL"), "http://user:pass@pr.proxynet.io:8000")
	proxyURL, err := url.Parse(raw)
	if err != nil {
		panic(err)
	}

	client := &http.Client{
		Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
		Timeout:   20 * time.Second,
	}

	for _, target := range []string{"https://httpbin.org/ip", "http://httpbin.org/ip"} {
		resp, err := client.Get(target)
		if err != nil {
			fmt.Println("error:", err)
			continue
		}
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("%s -> %d %s", target, resp.StatusCode, body)
	}
}
text
https://httpbin.org/ip -> 200 {
  "origin": "203.0.113.42"
}
http://httpbin.org/ip -> 200 {
  "origin": "203.0.113.42"
}

On the proxy side the two differ: one CONNECT for the HTTPS target, one forwarded GET with the full URL for the HTTP target, both with credentials:

text
13:25:51.052 [http-proxy] CONNECT httpbin.org:443 proxy-authorization=Basic user -> 200 (tunnel #1)
13:25:51.563 [http-proxy] GET http://httpbin.org/ip proxy-authorization=Basic user user-agent=Go-http-client/1.1 -> forward

Two habits: resp.Body.Close() after every response, or the connection cannot return to the pool, and a Timeout on the Client, or an exit that stops answering holds a goroutine forever. The default User-Agent is Go-http-client/1.1; set your own so the target can identify your program.

How does Go read HTTP_PROXY, HTTPS_PROXY and NO_PROXY?

http.ProxyFromEnvironment reads the three variables, or their lowercase forms, per request; the rules are in the httpproxy package documentation, the implementation net/http uses:

  • HTTP_PROXY applies to http:// targets, HTTPS_PROXY to https:// targets: the target's scheme decides, not the proxy's, so an http:// proxy address in HTTPS_PROXY is normal.
  • A value is a full URL or a bare host[:port], treated as http; anything else makes the request fail.
  • If both the uppercase and the lowercase variable exist, the lowercase one wins.
  • NO_PROXY lists IP addresses, CIDR ranges such as 10.0.0.0/8 and domain names, each optionally with a port. A domain matches itself and its subdomains; a name that starts with a dot matches subdomains only; a single * disables proxying.
  • localhost and loopback addresses are never proxied.

This program asks ProxyFromEnvironment about five targets without sending anything:

go
package main

import (
	"fmt"
	"net/http"
)

func main() {
	targets := []string{
		"https://httpbin.org/ip",
		"http://httpbin.org/ip",
		"https://api.internal.example.com/v1",
		"http://10.0.0.7:8080/health",
		"http://localhost:9000/",
	}
	for _, target := range targets {
		req, _ := http.NewRequest(http.MethodGet, target, nil)
		proxyURL, err := http.ProxyFromEnvironment(req)
		switch {
		case err != nil:
			fmt.Printf("%-36s error: %v\n", target, err)
		case proxyURL == nil:
			fmt.Printf("%-36s direct\n", target)
		default:
			fmt.Printf("%-36s via %s\n", target, proxyURL.Redacted())
		}
	}
}

With HTTPS_PROXY and HTTP_PROXY both set and NO_PROXY=.example.com,10.0.0.0/8:

text
https://httpbin.org/ip               via http://user:xxxxx@pr.proxynet.io:8000
http://httpbin.org/ip                via http://user:xxxxx@pr.proxynet.io:8000
https://api.internal.example.com/v1  direct
http://10.0.0.7:8080/health          direct
http://localhost:9000/               direct

With only HTTP_PROXY set, the HTTPS targets go direct, the most common reason a Go program "ignores the proxy":

text
https://httpbin.org/ip               direct
http://httpbin.org/ip                via http://user:xxxxx@pr.proxynet.io:8000
https://api.internal.example.com/v1  direct
http://10.0.0.7:8080/health          via http://user:xxxxx@pr.proxynet.io:8000
http://localhost:9000/               direct

Redacted() masks the password as xxxxx; use it in logs.

One rule is not in the variable semantics: net/http reads the environment once, on the first request, and caches it for the life of the process, so os.Setenv after that has no effect:

text
before Setenv: <nil>
after Setenv, net/http: <nil>
after Setenv, httpproxy: http://user:xxxxx@pr.proxynet.io:8000

To pick up a changed environment at runtime, put httpproxy.FromEnvironment().ProxyFunc(), which reads the variables on every call, into Transport.Proxy.

How does Go send the proxy username and password?

When the proxy URL has a user and a password, Go sends Proxy-Authorization: Basic … on its own. For a plain http:// target the header travels with every request, even on a kept-alive connection: in our retry test, four GETs on one socket each carried it. For an https:// target it is sent once, in the CONNECT, and the tunnel is reused for as long as the connection lives.

Characters such as @, : or / in the password break the URL unless they are percent-encoded, so build the URL from parts:

go
proxyURL := &url.URL{
	Scheme: "http",
	Host:   "pr.proxynet.io:8000",
	User:   url.UserPassword("user", "p@ss:word"), // encoded as p%40ss%3Aword on the wire
}

Basic is the only scheme Go sends to a proxy; NTLM or Kerberos needs a third-party package. A provider's gateway takes Basic or an IP whitelist, which leaves no password in the URL and suits a server with a fixed address; the two are compared in Proxy Authentication: User:Pass vs IP Whitelist.

How do you use a SOCKS5 proxy in Go?

Two ways. Transport.Proxy accepts a socks5:// URL and performs the handshake itself; Go treats socks5 exactly like socks5h, so the proxy resolves the target's host name. The golang.org/x/net/proxy package instead hands you the SOCKS5 dialer as a value, which you plug into Transport.DialContext and can also use for connections that are not HTTP.

go
package main

import (
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"time"

	"golang.org/x/net/proxy"
)

func main() {
	// SOCKS_URL=socks5://user:pass@pr.proxynet.io:<the SOCKS5 port the endpoint builder shows>
	socksURL, err := url.Parse(os.Getenv("SOCKS_URL"))
	if err != nil || socksURL.Host == "" {
		log.Fatal("set SOCKS_URL to socks5://user:pass@host:port")
	}

	// 1. net/http handles socks5:// on its own. The proxy resolves the host name.
	viaTransport := &http.Client{
		Transport: &http.Transport{Proxy: http.ProxyURL(socksURL)},
		Timeout:   20 * time.Second,
	}
	show("Transport.Proxy", viaTransport)

	// 2. golang.org/x/net/proxy gives you the SOCKS5 dialer itself, usable for any TCP connection.
	password, _ := socksURL.User.Password()
	auth := &proxy.Auth{User: socksURL.User.Username(), Password: password}
	dialer, err := proxy.SOCKS5("tcp", socksURL.Host, auth, &net.Dialer{Timeout: 10 * time.Second})
	if err != nil {
		log.Fatal(err)
	}
	viaDialer := &http.Client{
		Transport: &http.Transport{DialContext: dialer.(proxy.ContextDialer).DialContext},
		Timeout:   20 * time.Second,
	}
	show("x/net/proxy dialer", viaDialer)
}

func show(label string, client *http.Client) {
	resp, err := client.Get("https://httpbin.org/ip")
	if err != nil {
		fmt.Printf("%-19s error: %v\n", label, err)
		return
	}
	body, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	fmt.Printf("%-19s %d %s", label, resp.StatusCode, body)
}
text
Transport.Proxy     200 {
  "origin": "203.0.113.42"
}
x/net/proxy dialer  200 {
  "origin": "203.0.113.42"
}

Our SOCKS5 server logged the same three steps for both clients: Go offered the "no authentication" and "username/password" methods, the server chose username/password as defined in RFC 1929, and the connect request carried the domain name, not an IP:

text
13:26:07.490 [socks5] greeting methods=[0,2]
13:26:07.490 [socks5] auth user=user -> OK
13:26:07.490 [socks5] CONNECT domain httpbin.org:443

With the dialer route, leave Transport.Proxy empty; the dialer already goes through the proxy. SOCKS5 carries TCP and UDP, but net/http only opens TCP connections. Proxynet's SOCKS5 Proxy runs on its own port next to the HTTP port, with the same username and password.

Which method fits which situation?

MethodWhere it goesScopeNote
http.ProxyURL(u)Transport.Proxythat Transportone fixed proxy
http.ProxyFromEnvironmentTransport.Proxy (default in DefaultTransport)the processread once; localhost bypassed
your own func(*http.Request)Transport.Proxyper requestrotation, per-host rules
socks5:// URLany of the three abovethat TransportDNS resolved at the proxy
x/net/proxy dialerTransport.DialContextthat Transportalso raw TCP; keep Proxy empty

How do you use a different proxy for each request?

Transport.Proxy is an ordinary function, so it can return a different URL on every call. This one picks round-robin from a list and prints its choice:

go
var next atomic.Uint64
transport := &http.Transport{
	// Round-robin over the list. Called when the Transport needs a connection for a request.
	Proxy: func(req *http.Request) (*url.URL, error) {
		u := proxies[(next.Add(1)-1)%uint64(len(proxies))]
		fmt.Printf("Proxy func: %s %s via %s\n", req.Method, req.URL, u.Redacted())
		return u, nil
	},
	DisableKeepAlives: true, // a new connection, and therefore a new proxy choice, for every request
}

The last line matters. We sent four GETs to https://httpbin.org/ip, which negotiates HTTP/2, with two proxies in the list, and counted calls and tunnels:

Transport settingsProxy function calledTunnels opened
Defaults (HTTP/2 negotiated)11, first proxy only
HTTP/1.1 only (empty TLSNextProto map)42, one per proxy, reused
DisableKeepAlives: true44, alternating

With the defaults, Go opened one HTTP/2 connection through the first proxy and multiplexed all four requests over it; the function was never asked again. With HTTP/1.1 keep-alive it was asked each time, but requests three and four reused the idle connection to their proxy. Only DisableKeepAlives, or req.Close = true, gives one tunnel per request. A rotating gateway follows the same rule, because the exit IP is chosen when the tunnel opens: a Residential Proxy gateway rotates the exit for every new tunnel through one address, and a session parameter in the username holds an exit for 1 to 60 minutes for login flows.

Timeouts, connection limits and TLS

http.Client.Timeout is the one setting never to leave at zero: it covers the whole request, dial to last byte. The finer knobs live on the Transport: DialContext with a net.Dialer{Timeout: …} bounds the connection to the proxy, TLSHandshakeTimeout the handshake inside the tunnel, ResponseHeaderTimeout the wait for headers; http.NewRequestWithContext with a deadline does the same for one request.

Three fields decide how many connections you open. MaxConnsPerHost caps connections per host and makes further dials wait, a cheap per-target rate limiter. MaxIdleConnsPerHost defaults to 2; with more workers, finished connections are closed instead of pooled, so set it to the worker count. IdleConnTimeout closes connections that sat unused.

For TLS, never reach for InsecureSkipVerify. x509: certificate signed by unknown authority behind a proxy means a corporate proxy inspects TLS with its own certificate authority; add that CA to tls.Config.RootCAs. A provider's gateway never touches the TLS inside the tunnel, so there the error points at the target's certificate.

A complete example: retries, backoff and a concurrency cap

The fetcher below combines the pieces: the timeouts and pool limits above, retries for network errors, 429 and 5xx with exponential backoff and jitter, Retry-After honoured, no retry on a 407, and at most four requests at once:

go
// fetch.go - GET a list of URLs through a proxy with timeouts, retries, backoff and a concurrency cap.
package main

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"math/rand/v2"
	"net"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"sync"
	"sync/atomic"
	"time"
)

const (
	maxAttempts = 4 // first try + 3 retries
	maxInFlight = 4 // requests running at the same time
)

var (
	retryStatus  = map[int]bool{429: true, 500: true, 502: true, 503: true, 504: true}
	errProxyAuth = errors.New("proxy rejected the credentials (407): check user:pass or the IP whitelist")
)

func newClient(proxyURL *url.URL) *http.Client {
	return &http.Client{
		Timeout: 30 * time.Second, // the whole request: connect, headers and body
		Transport: &http.Transport{
			Proxy:                 http.ProxyURL(proxyURL),
			DialContext:           (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
			TLSHandshakeTimeout:   10 * time.Second,
			ResponseHeaderTimeout: 15 * time.Second,
			MaxConnsPerHost:       maxInFlight,
			MaxIdleConnsPerHost:   maxInFlight,
			IdleConnTimeout:       60 * time.Second,
			// A 407 on the CONNECT tunnel becomes a typed error instead of "Proxy Authentication Required".
			OnProxyConnectResponse: func(_ context.Context, _ *url.URL, _ *http.Request, res *http.Response) error {
				if res.StatusCode == http.StatusProxyAuthRequired {
					return errProxyAuth
				}
				return nil
			},
		},
	}
}

// backoff: 1, 2, 4 s ... capped at 30 s, plus up to 1 s of jitter so workers do not retry in lockstep.
func backoff(attempt int) time.Duration {
	d := min(time.Duration(1<<(attempt-1))*time.Second, 30*time.Second)
	return d + time.Duration(rand.IntN(1000))*time.Millisecond
}

func sleep(ctx context.Context, d time.Duration) {
	select {
	case <-ctx.Done():
	case <-time.After(d):
	}
}

// fetch GETs one URL. Network errors, 429 and 5xx are retried; a 407 is not, because the
// credentials are wrong and another attempt cannot fix that.
func fetch(ctx context.Context, client *http.Client, target string) ([]byte, int, error) {
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
		if err != nil {
			return nil, 0, err
		}
		req.Header.Set("User-Agent", "price-monitor/1.0 (+mailto:you@example.com)")

		resp, err := client.Do(req)
		if err != nil {
			if errors.Is(err, errProxyAuth) || ctx.Err() != nil {
				return nil, 0, err
			}
			lastErr = err
			log.Printf("attempt %d/%d %s: %v", attempt, maxAttempts, target, err)
			sleep(ctx, backoff(attempt))
			continue
		}
		body, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if resp.StatusCode == http.StatusProxyAuthRequired { // plain-http target: the 407 is a response
			return body, resp.StatusCode, errProxyAuth
		}
		if !retryStatus[resp.StatusCode] {
			return body, resp.StatusCode, err // 200, 404, 301 ... the caller decides
		}
		lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
		if attempt == maxAttempts {
			break
		}
		// The site asked us to slow down. Its own Retry-After wins over our schedule.
		wait := backoff(attempt)
		if secs, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
			wait = time.Duration(secs) * time.Second
		}
		log.Printf("attempt %d/%d %s: HTTP %d, sleeping %s", attempt, maxAttempts, target, resp.StatusCode, wait.Round(100*time.Millisecond))
		sleep(ctx, wait)
	}
	return nil, 0, lastErr
}

func main() {
	proxyURL, err := url.Parse(cmp.Or(os.Getenv("PROXY_URL"), "http://user:pass@pr.proxynet.io:8000"))
	if err != nil {
		log.Fatal(err)
	}
	targets := os.Args[1:]
	if len(targets) == 0 {
		for i := 1; i <= 4; i++ {
			targets = append(targets, fmt.Sprintf("https://httpbin.org/ip?n=%d", i))
		}
		targets = append(targets, "https://httpbin.org/status/503")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()
	client := newClient(proxyURL)
	sem := make(chan struct{}, maxInFlight)
	var wg sync.WaitGroup
	var ok, failed atomic.Int32

	for _, target := range targets {
		wg.Add(1)
		sem <- struct{}{} // blocks while maxInFlight requests are running
		go func() {
			defer wg.Done()
			defer func() { <-sem }()
			started := time.Now()
			body, status, err := fetch(ctx, client, target)
			if err != nil {
				failed.Add(1)
				log.Printf("fail %s -> %v", target, err)
				return
			}
			ok.Add(1)
			log.Printf("ok   %s -> %d, %d bytes in %s", target, status, len(body), time.Since(started).Round(100*time.Millisecond))
		}()
	}
	wg.Wait()
	log.Printf("done: %d ok, %d failed", ok.Load(), failed.Load())
}

Five URLs, four workers, one address that always answers 503 to exercise the retry path:

text
2026/10/28 16:30:56 ok   https://httpbin.org/ip?n=1 -> 200, 32 bytes in 500ms
2026/10/28 16:30:56 ok   https://httpbin.org/ip?n=2 -> 200, 32 bytes in 500ms
2026/10/28 16:30:56 ok   https://httpbin.org/ip?n=4 -> 200, 32 bytes in 500ms
2026/10/28 16:30:56 attempt 1/4 https://httpbin.org/status/503: HTTP 503, sleeping 1.3s
2026/10/28 16:30:56 ok   https://httpbin.org/ip?n=3 -> 200, 32 bytes in 700ms
2026/10/28 16:30:58 attempt 2/4 https://httpbin.org/status/503: HTTP 503, sleeping 2.8s
2026/10/28 16:31:00 attempt 3/4 https://httpbin.org/status/503: HTTP 503, sleeping 4.8s
2026/10/28 16:31:05 fail https://httpbin.org/status/503 -> HTTP 503
2026/10/28 16:31:05 done: 4 ok, 1 failed

Three design points. OnProxyConnectResponse runs when the proxy answers the CONNECT, before Go turns a non-200 status into a plain error string; a typed error there lets errors.Is stop the retry loop, and with a wrong password the program printed fail … -> proxy rejected the credentials (407) for an HTTPS and a plain HTTP target without a single retry. The retry set is small on purpose: a 404 or 403 is returned as-is, because another exit does not change what the page is. Against a local server that sent Retry-After: 2, the loop slept exactly 2 s between attempts instead of its own schedule.

How do you check that the request really uses the proxy?

Ask an echo service which address it sees, once with Proxy: nil and once with the proxy. The nil is deliberate: a Transport without a Proxy value never reads the environment, so the first line is your own address even with HTTPS_PROXY set.

go
direct := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: 20 * time.Second}
proxied := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, Timeout: 20 * time.Second}

fmt.Println("without proxy:", exitIP(direct))  // exitIP GETs https://api.ipify.org?format=json and returns the "ip" field
fmt.Println("with proxy:   ", exitIP(proxied))

With a real gateway the second line shows the exit's address. With our test proxy both lines printed the same address, because the proxy runs on the same machine; the proof was the CONNECT api.ipify.org:443 line in its log. Two identical lines with a real proxy usually mean HTTP_PROXY without HTTPS_PROXY, a target in NO_PROXY, or a Transport created without a Proxy value.

What about Colly?

Colly, the most used Go crawling framework, builds on http.Transport, so everything above applies. Its proxy package ships a RoundRobinProxySwitcher that takes http://, https:// or socks5:// URLs and returns a colly.ProxyFunc, the same signature as Transport.Proxy; you attach it with c.SetProxyFunc(rp), as in the proxy switcher example in Colly's documentation. The switcher is called per request, but the connection-reuse rule from the rotation section still decides whether a request opens a new tunnel.

Common errors and what they mean

Messages as Go 1.26.6 printed them in our tests, with the placeholder host:

  • Get "https://httpbin.org/ip": Proxy Authentication Required for an HTTPS target, or status 407 as a normal response for a plain HTTP target: wrong user:pass, unencoded special characters, or an IP not on the whitelist. The causes are collected in HTTP 407 Proxy Authentication Required.
  • proxyconnect tcp: dial tcp pr.proxynet.io:8000: … plus the operating system's refused-connection text (connect: connection refused on Linux and macOS, connectex: and a localized sentence on Windows): nothing listens there. proxyconnect says the failure was on the way to the proxy, not to the target.
  • socks connect tcp pr.proxynet.io:8000->httpbin.org:443: unexpected protocol version 72: a socks5:// URL pointed at the HTTP port; 72 is the byte value of the H in HTTP/1.1.
  • socks connect tcp …: username/password authentication failed: the SOCKS5 server rejected the credentials.
  • unexpected EOF: an http:// URL at the SOCKS5 port, which closed the connection on the CONNECT; an unknown scheme such as socks4:// is treated like http and ends the same way.
  • proxyconnect tcp: tls: first record does not look like a TLS handshake: an https:// proxy scheme at a plain HTTP port; use http:// unless the provider says the port runs TLS.
  • The proxy is simply not used: HTTP_PROXY without HTTPS_PROXY, a target that is localhost or in NO_PROXY, variables set after the first request, or a Transport built without a Proxy value.

Where this setup is used

  • Price and availability checks from several countries: a country parameter in the proxy username and per-request rotation for independent product pages.
  • API clients behind an allowlisted address, where a partner has whitelisted one static IP and the service must leave through it.
  • Monitoring from outside your own network, so a health check sees the site the way a visitor in another region does.
  • Workers in a corporate network that reach the internet only through the company proxy.

Common mistakes

  • Setting HTTP_PROXY only. HTTPS targets go direct; set HTTPS_PROXY as well.
  • Changing the environment after the first request. net/http caches it; restart the process or use httpproxy.
  • Expecting rotation with keep-alives on. A reused connection never calls Proxy again.
  • Forgetting resp.Body.Close(). Connections never return to the pool.
  • No Client.Timeout. A silent exit blocks a goroutine forever.
  • Pasting a password with @ or : into the URL string. Build it with url.UserPassword.
  • Setting both Transport.Proxy and a SOCKS5 DialContext. Pick one.
  • Retrying a 407. The second attempt sends the same wrong credentials.

Which approach should you choose?

Your situationRecommendation
One proxy, no code changesHTTPS_PROXY and HTTP_PROXY, NO_PROXY for internal hosts
One proxy, set in codeTransport{Proxy: http.ProxyURL(u)} plus Client.Timeout
A new exit for every requestYour own Proxy function or a rotating gateway, DisableKeepAlives: true
The same exit for a login flowA session parameter in the username, keep-alives on
SOCKS5 with DNS at the proxysocks5://user:pass@host:port in Transport.Proxy
SOCKS5 for a non-HTTP connectiongolang.org/x/net/proxy dialer
Many workers on one siteMaxConnsPerHost and MaxIdleConnsPerHost = worker count
Corporate proxy with its own CAThe CA in tls.Config.RootCAs, never InsecureSkipVerify

Frequently asked questions

Does http.Get in Go use a proxy automatically?

Yes, when the environment says so: http.Get uses http.DefaultClient, whose Transport sets Proxy to http.ProxyFromEnvironment. A Transport you create yourself starts with no proxy.

Is this the same as GOPROXY?

No. GOPROXY tells the go command where to download modules from; it has nothing to do with the requests your program sends at run time.

How do I set a proxy for one request only?

Give that request its own Client and Transport, or write a Proxy function that returns nil for requests that should go direct. A pooled connection to the same host may already exist; req.Close = true forces a fresh one.

Does Go support socks5h?

Yes, and socks5 behaves the same way: Go always sends the host name to the proxy, which resolves it; net/http has no local-resolution SOCKS5 mode.

Why is localhost never proxied?

ProxyFromEnvironment returns no proxy for localhost and loopback addresses, whatever the variables say; to proxy such requests, use http.ProxyURL instead.

How can I see which proxy a request used?

Wrap the Proxy function and log u.Redacted(), or set Transport.OnProxyConnectResponse, which receives the proxy URL and the CONNECT response. With keep-alives on, a choice is logged only when a new connection opens.

Summary

In Go the proxy is a function on http.Transport: http.ProxyURL for a fixed address, http.ProxyFromEnvironment for the standard variables, or your own function for per-request choice. Put user:pass in the URL and Go sends Proxy-Authorization on its own; use socks5:// for SOCKS5; set Client.Timeout and the pool limits; retry 429 and 5xx with backoff but never a 407; turn keep-alives off when each request needs a new exit. Residential, static ISP, datacenter and SOCKS5 endpoints that work with these settings are on the Proxynet proxy page.

Ask ChatGPTAsk Claude