---
title: "Golang HTTP Proxy: How to Use a Proxy With net/http"
description: "Set http.Transport.Proxy with http.ProxyURL or ProxyFromEnvironment and put user:pass in the proxy URL. SOCKS5, timeouts, retries and rotation on Go 1.26."
url: https://proxynet.io/blog/golang-proxy
date: 2026-10-28
author: "Acar Diveroli"
category: "Tutorial, Integration"
lang: en
---

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

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](/blog/nodejs-proxy).

> **Note: Short answer**
>
> Build an `http.Transport` with `Proxy: http.ProxyURL(u)`, where `u` is the parsed `http://user:pass@pr.proxynet.io:8000`, and give it to an `http.Client` with a `Timeout`; Go sends the username and password as `Proxy-Authorization` on its own. For SOCKS5, use the `socks5://` scheme in the same URL. `http.Get` uses `http.ProxyFromEnvironment`, so a plain program already honours `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`. For a different proxy per request, write your own `Proxy` function and turn keep-alives off, because a reused connection never asks it again.

## 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](https://pkg.go.dev/net/http#Transport) 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](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.6) 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](https://pkg.go.dev/golang.org/x/net/http/httpproxy), 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 `GET`s 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](/blog/proxy-authentication-methods).

## 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](https://www.rfc-editor.org/rfc/rfc1929), 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](https://proxynet.io/socks5-proxy) runs on its own port next to the HTTP port, with the same username and password.

## Which method fits which situation?

| Method | Where it goes | Scope | Note |
|---|---|---|---|
| `http.ProxyURL(u)` | `Transport.Proxy` | that Transport | one fixed proxy |
| `http.ProxyFromEnvironment` | `Transport.Proxy` (default in `DefaultTransport`) | the process | read once; `localhost` bypassed |
| your own `func(*http.Request)` | `Transport.Proxy` | per request | rotation, per-host rules |
| `socks5://` URL | any of the three above | that Transport | DNS resolved at the proxy |
| `x/net/proxy` dialer | `Transport.DialContext` | that Transport | also 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 `GET`s to `https://httpbin.org/ip`, which negotiates HTTP/2, with two proxies in the list, and counted calls and tunnels:

| Transport settings | `Proxy` function called | Tunnels opened |
|---|---|---|
| Defaults (HTTP/2 negotiated) | 1 | 1, first proxy only |
| HTTP/1.1 only (empty `TLSNextProto` map) | 4 | 2, one per proxy, reused |
| `DisableKeepAlives: true` | 4 | 4, 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](https://proxynet.io/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](https://go-colly.org/docs/examples/proxy_switcher/) 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](/blog/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 situation | Recommendation |
|---|---|
| One proxy, no code changes | `HTTPS_PROXY` and `HTTP_PROXY`, `NO_PROXY` for internal hosts |
| One proxy, set in code | `Transport{Proxy: http.ProxyURL(u)}` plus `Client.Timeout` |
| A new exit for every request | Your own `Proxy` function or a rotating gateway, `DisableKeepAlives: true` |
| The same exit for a login flow | A session parameter in the username, keep-alives on |
| SOCKS5 with DNS at the proxy | `socks5://user:pass@host:port` in `Transport.Proxy` |
| SOCKS5 for a non-HTTP connection | `golang.org/x/net/proxy` dialer |
| Many workers on one site | `MaxConnsPerHost` and `MaxIdleConnsPerHost` = worker count |
| Corporate proxy with its own CA | The 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](/proxy) page.
