Proxy HTTP en Golang: cómo usar un proxy con net/http

Publicado:

16 min de lectura

Acar Diveroli
Autor: Acar Diveroli
Tres paradas en una ruta: un cliente net/http, una gran parada proxy azul con icono de túnel y un destino que responde 200 OK

Tu servicio en Go tiene que enviar sus peticiones a través de un proxy: una salida en otro país, una IP fija que un socio ha puesto en su lista de IP autorizadas, o un pool para que una sola dirección no cargue con todas las peticiones. Go no necesita ningún paquete extra para esto; net/http trae el soporte de proxy integrado, SOCKS5 y las variables de entorno estándar incluidos. Las sorpresas están en los valores por defecto: http.Get ya lee HTTPS_PROXY, localhost nunca pasa por el proxy, las variables se leen una sola vez por proceso y una conexión HTTP/2 puede saltarse en silencio la función de proxy que escribiste para la rotación.

Esta guía cubre http.ProxyURL, ProxyFromEnvironment y las reglas de NO_PROXY, la cabecera Proxy-Authorization, SOCKS5 en las dos formas que ofrece Go, un proxy distinto por petición, timeouts, reintentos, los textos exactos de los errores y un descargador completo. Cada fragmento se ejecutó en Go 1.26.6 (golang.org/x/net v0.62.0) contra un proxy HTTP local que exige user:pass y un servidor SOCKS5 local; las salidas y las líneas de registro son reales, solo el host del proxy se sustituyó por pr.proxynet.io:8000. El lado Node.js del mismo trabajo está en Cómo usar un proxy en Node.js.

¿Cómo decide net/http qué proxy usar?

Cada petición pasa por un http.Transport, cuyo campo Proxy es una función que recibe la petición y devuelve la URL del proxy, o nil para una conexión directa. El esquema de la URL elige el tipo de proxy: la documentación de Transport en net/http enumera http, https, socks5 y socks5h, un esquema vacío significa http, y las credenciales de la URL se convierten en una cabecera Proxy-Authorization. http.DefaultTransport, el que está detrás de http.Get, pone http.ProxyFromEnvironment en Proxy; un Transport que construyes tú no usa proxy hasta que le pones uno.

Después, según el destino:

  1. El Transport se conecta al proxy, no al destino; si falta el puerto, usa 80 para http, 443 para https y 1080 para SOCKS.
  2. Destino http:// sin cifrar. La petición va al proxy con la URL completa en la línea de petición, más Proxy-Authorization en cada petición.
  3. Destino https://. Go envía CONNECT host:443 con la cabecera una sola vez; tras el 200 del proxy, el handshake TLS se hace dentro del túnel y el proxy solo ve el host y el puerto. El método CONNECT está definido en la RFC 9110.
  4. Proxy socks5://. Go hace el handshake SOCKS, inicia sesión si la URL lleva credenciales y envía el nombre de host del destino, así que el DNS lo resuelve el proxy.
  5. La conexión se guarda en el pool para la siguiente petición al mismo destino por el mismo proxy, con keep-alive de HTTP/1.1 o con HTTP/2, y una conexión reutilizada no vuelve a llamar a Proxy.

¿Cómo se configura un proxy con http.ProxyURL?

http.ProxyURL envuelve una URL fija en una función con la forma adecuada. Parsea la dirección una vez, ponla en un Transport y dale el Transport a un Client que también lleve un timeout:

go
package main

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

func main() {
	// El Generador de endpoints del panel imprime esta cadena. Guárdala en una variable de entorno, no en 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"
}

En el lado del proxy las dos difieren: un CONNECT para el destino HTTPS y un GET reenviado con la URL completa para el destino HTTP, ambos con credenciales:

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

Dos hábitos: resp.Body.Close() después de cada respuesta, o la conexión no puede volver al pool, y un Timeout en el Client, o una salida que deja de responder retiene una goroutine para siempre. El User-Agent por defecto es Go-http-client/1.1; pon el tuyo para que el destino pueda identificar tu programa.

¿Cómo lee Go HTTP_PROXY, HTTPS_PROXY y NO_PROXY?

http.ProxyFromEnvironment lee las tres variables, o sus formas en minúsculas, en cada petición; las reglas están en la documentación del paquete httpproxy, la implementación que usa net/http:

  • HTTP_PROXY se aplica a destinos http:// y HTTPS_PROXY a destinos https://: decide el esquema del destino, no el del proxy, así que una dirección de proxy http:// en HTTPS_PROXY es normal.
  • Un valor es una URL completa o un host[:port] a secas, que se trata como http; cualquier otra cosa hace fallar la petición.
  • Si existen la variable en mayúsculas y la variable en minúsculas, gana la de minúsculas.
  • NO_PROXY lista direcciones IP, rangos CIDR como 10.0.0.0/8 y nombres de dominio, cada uno con puerto opcional. Un dominio coincide consigo mismo y con sus subdominios; un nombre que empieza por punto coincide solo con los subdominios; un único * desactiva el proxy.
  • localhost y las direcciones de loopback nunca pasan por el proxy.

Este programa pregunta a ProxyFromEnvironment por cinco destinos sin enviar nada:

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())
		}
	}
}

Con HTTPS_PROXY y HTTP_PROXY definidas y 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

Con solo HTTP_PROXY, los destinos HTTPS van directos, la causa más común de que un programa en Go «ignore el 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() enmascara la contraseña como xxxxx; úsalo en los registros.

Hay una regla que no está en la semántica de las variables: net/http lee el entorno una vez, en la primera petición, y lo guarda en caché durante toda la vida del proceso, así que un os.Setenv posterior no tiene efecto:

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

Para recoger un entorno cambiado en tiempo de ejecución, pon en Transport.Proxy la función httpproxy.FromEnvironment().ProxyFunc(), que lee las variables en cada llamada.

¿Cómo envía Go el usuario y la contraseña del proxy?

Cuando la URL del proxy lleva usuario y contraseña, Go envía Proxy-Authorization: Basic … por su cuenta. Con un destino http:// sin cifrar, la cabecera viaja con cada petición, incluso en una conexión keep-alive: en nuestra prueba de reintentos, cada uno de los cuatro GET de un mismo socket la llevaba. Con un destino https:// se envía una vez, en el CONNECT, y el túnel se reutiliza mientras viva la conexión.

Caracteres como @, : o / en la contraseña rompen la URL si no van codificados en porcentaje, así que construye la URL por partes:

go
proxyURL := &url.URL{
	Scheme: "http",
	Host:   "pr.proxynet.io:8000",
	User:   url.UserPassword("user", "p@ss:word"), // en la red va codificada como p%40ss%3Aword
}

Basic es el único esquema que Go envía a un proxy; NTLM o Kerberos necesita un paquete de terceros. El gateway de un proveedor acepta Basic o una lista de IP autorizadas, que no deja ninguna contraseña en la URL y encaja con un servidor de dirección fija; comparamos las dos en Autenticación de proxy: user:pass o lista blanca de IP.

¿Cómo se usa un proxy SOCKS5 en Go?

Hay dos formas. Transport.Proxy acepta una URL socks5:// y hace el handshake por sí mismo; Go trata socks5 exactamente igual que socks5h, así que el proxy resuelve el nombre de host del destino. El paquete golang.org/x/net/proxy, en cambio, te entrega el dialer SOCKS5 como un valor que conectas a Transport.DialContext y que también puedes usar en conexiones que no son 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:<el puerto SOCKS5 que muestra el Generador de endpoints>
	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 gestiona socks5:// por su cuenta. El proxy resuelve el nombre de host.
	viaTransport := &http.Client{
		Transport: &http.Transport{Proxy: http.ProxyURL(socksURL)},
		Timeout:   20 * time.Second,
	}
	show("Transport.Proxy", viaTransport)

	// 2. golang.org/x/net/proxy te da el propio dialer SOCKS5, válido para cualquier conexión TCP.
	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"
}

Nuestro servidor SOCKS5 registró los mismos tres pasos con los dos clientes: Go ofreció los métodos «sin autenticación» y «usuario/contraseña», el servidor eligió usuario/contraseña tal como lo define la RFC 1929, y la petición de conexión llevaba el nombre de dominio, no una 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

Con la vía del dialer, deja Transport.Proxy vacío; el dialer ya pasa por el proxy. SOCKS5 transporta TCP y UDP, pero net/http solo abre conexiones TCP. Los Proxies SOCKS5 de Proxynet funcionan en su propio puerto junto al puerto HTTP, con el mismo usuario y la misma contraseña.

¿Qué método encaja en cada situación?

MétodoDónde vaAlcanceNota
http.ProxyURL(u)Transport.Proxyese Transportun proxy fijo
http.ProxyFromEnvironmentTransport.Proxy (por defecto en DefaultTransport)el procesose lee una vez; localhost queda fuera
tu propia func(*http.Request)Transport.Proxypor peticiónrotación, reglas por host
URL socks5://cualquiera de las tres anterioresese Transportel DNS se resuelve en el proxy
dialer de x/net/proxyTransport.DialContextese Transporttambién TCP puro; deja Proxy vacío

¿Cómo se usa un proxy distinto en cada petición?

Transport.Proxy es una función normal, así que puede devolver una URL distinta en cada llamada. Esta elige por turnos de una lista e imprime su elección:

go
var next atomic.Uint64
transport := &http.Transport{
	// Turno rotatorio sobre la lista. Se llama cuando el Transport necesita una conexión para una petición.
	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, // una conexión nueva, y por tanto una elección de proxy nueva, en cada petición
}

La última línea es la que importa. Enviamos cuatro GET a https://httpbin.org/ip, que negocia HTTP/2, con dos proxies en la lista, y contamos las llamadas y los túneles:

Ajustes del TransportLlamadas a la función ProxyTúneles abiertos
Valores por defecto (HTTP/2 negociado)11, solo el primer proxy
Solo HTTP/1.1 (mapa TLSNextProto vacío)42, uno por proxy, reutilizados
DisableKeepAlives: true44, alternando

Con los valores por defecto, Go abrió una conexión HTTP/2 por el primer proxy y multiplexó las cuatro peticiones por ella; la función no volvió a consultarse. Con keep-alive de HTTP/1.1 se consultó cada vez, pero las peticiones tres y cuatro reutilizaron la conexión inactiva con su proxy. Solo DisableKeepAlives, o req.Close = true, da un túnel por petición. Un gateway rotativo sigue la misma regla, porque la IP de salida se elige al abrir el túnel: un gateway de Proxies residenciales cambia la salida en cada túnel nuevo a través de una sola dirección, y un parámetro de sesión en el nombre de usuario mantiene una salida entre 1 y 60 minutos para los flujos con inicio de sesión.

Timeouts, límites de conexiones y TLS

http.Client.Timeout es el único ajuste que nunca debe quedarse en cero: cubre la petición entera, desde la conexión hasta el último byte. Los ajustes finos viven en el Transport: DialContext con un net.Dialer{Timeout: …} acota la conexión al proxy, TLSHandshakeTimeout el handshake dentro del túnel, ResponseHeaderTimeout la espera de las cabeceras; http.NewRequestWithContext con un plazo hace lo mismo para una sola petición.

Tres campos deciden cuántas conexiones abres. MaxConnsPerHost limita las conexiones por host y hace esperar a las demás, un limitador de velocidad barato por destino. MaxIdleConnsPerHost vale 2 por defecto; con más workers, las conexiones terminadas se cierran en lugar de guardarse en el pool, así que ponlo igual al número de workers. IdleConnTimeout cierra las conexiones que llevan tiempo sin usarse.

En TLS, no recurras nunca a InsecureSkipVerify. x509: certificate signed by unknown authority detrás de un proxy significa que un proxy corporativo inspecciona el TLS con su propia autoridad de certificación; añade esa CA a tls.Config.RootCAs. El gateway de un proveedor nunca toca el TLS de dentro del túnel, así que ahí el error apunta al certificado del destino.

Un ejemplo completo: reintentos, backoff y límite de concurrencia

El descargador de abajo combina las piezas: los timeouts y los límites de pool anteriores, reintentos ante errores de red, 429 y 5xx con backoff exponencial y jitter, respeto a Retry-After, ningún reintento ante un 407 y como máximo cuatro peticiones a la vez:

go
// fetch.go - hace GET a una lista de URL a través de un proxy con timeouts, reintentos, backoff y límite de concurrencia.
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 // primer intento + 3 reintentos
	maxInFlight = 4 // peticiones en ejecución al mismo tiempo
)

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, // la petición entera: conexión, cabeceras y cuerpo
		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,
			// Un 407 en el túnel CONNECT se convierte en un error tipado en lugar de "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 ... con tope en 30 s, más hasta 1 s de jitter para que los workers no reintenten al unísono.
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 hace GET a una URL. Los errores de red, 429 y 5xx se reintentan; un 407 no, porque las
// credenciales son incorrectas y otro intento no puede arreglarlo.
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 { // destino http sin cifrar: el 407 es una respuesta
			return body, resp.StatusCode, errProxyAuth
		}
		if !retryStatus[resp.StatusCode] {
			return body, resp.StatusCode, err // 200, 404, 301 ... decide quien llama
		}
		lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
		if attempt == maxAttempts {
			break
		}
		// El sitio nos pidió ir más despacio. Su propio Retry-After manda sobre nuestro calendario.
		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{}{} // se bloquea mientras haya maxInFlight peticiones en curso
		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())
}

Cinco URL, cuatro workers, una dirección que siempre responde 503 para ejercitar la ruta de reintentos:

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

Tres decisiones de diseño. OnProxyConnectResponse se ejecuta cuando el proxy responde al CONNECT, antes de que Go convierta un estado distinto de 200 en una simple cadena de error; un error tipado ahí permite que errors.Is detenga el bucle de reintentos, y con una contraseña incorrecta el programa imprimió fail … -> proxy rejected the credentials (407) para un destino HTTPS y otro HTTP sin cifrar sin un solo reintento. El conjunto de reintentos es pequeño a propósito: un 404 o un 403 se devuelve tal cual, porque otra salida no cambia lo que es la página. Contra un servidor local que enviaba Retry-After: 2, el bucle durmió exactamente 2 s entre intentos en lugar de seguir su propio calendario.

¿Cómo se comprueba que la petición usa de verdad el proxy?

Pregunta a un servicio de eco qué dirección ve, una vez con Proxy: nil y otra con el proxy. El nil es deliberado: un Transport sin valor en Proxy nunca lee el entorno, así que la primera línea es tu propia dirección incluso con HTTPS_PROXY definida.

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 hace GET a https://api.ipify.org?format=json y devuelve el campo "ip"
fmt.Println("with proxy:   ", exitIP(proxied))

Con un gateway real, la segunda línea muestra la dirección de la salida. Con nuestro proxy de prueba, las dos líneas imprimieron la misma dirección, porque el proxy corre en la misma máquina; la prueba fue la línea CONNECT api.ipify.org:443 de su registro. Dos líneas idénticas con un proxy real suelen significar HTTP_PROXY sin HTTPS_PROXY, un destino incluido en NO_PROXY o un Transport creado sin valor en Proxy.

¿Y qué pasa con Colly?

Colly, el framework de crawling más usado en Go, se construye sobre http.Transport, así que todo lo anterior se aplica. Su paquete proxy incluye un RoundRobinProxySwitcher que recibe URL http://, https:// o socks5:// y devuelve una colly.ProxyFunc, la misma firma que Transport.Proxy; lo conectas con c.SetProxyFunc(rp), como en el ejemplo del proxy switcher de la documentación de Colly. El switcher se llama en cada petición, pero la regla de reutilización de conexiones de la sección de rotación sigue decidiendo si una petición abre un túnel nuevo.

Errores comunes y qué significan

Los mensajes tal como los imprimió Go 1.26.6 en nuestras pruebas, con el host de ejemplo:

  • Get "https://httpbin.org/ip": Proxy Authentication Required con un destino HTTPS, o estado 407 como respuesta normal con un destino HTTP sin cifrar: user:pass incorrecto, caracteres especiales sin codificar o una IP que no está en la lista de IP autorizadas. Las causas están reunidas en Cómo solucionar HTTP 407 Proxy Authentication Required.
  • proxyconnect tcp: dial tcp pr.proxynet.io:8000: … más el texto de conexión rechazada del sistema operativo (connect: connection refused en Linux y macOS, connectex: y una frase localizada en Windows): ahí no escucha nada. proxyconnect indica que el fallo ocurrió de camino al proxy, no al destino.
  • socks connect tcp pr.proxynet.io:8000->httpbin.org:443: unexpected protocol version 72: una URL socks5:// apuntaba al puerto HTTP; 72 es el valor en bytes de la H de HTTP/1.1.
  • socks connect tcp …: username/password authentication failed: el servidor SOCKS5 rechazó las credenciales.
  • unexpected EOF: una URL http:// en el puerto SOCKS5, que cerró la conexión al recibir el CONNECT; un esquema desconocido como socks4:// se trata como http y termina igual.
  • proxyconnect tcp: tls: first record does not look like a TLS handshake: un esquema de proxy https:// en un puerto HTTP normal; usa http:// salvo que el proveedor diga que el puerto habla TLS.
  • El proxy simplemente no se usa: HTTP_PROXY sin HTTPS_PROXY, un destino que es localhost o está en NO_PROXY, variables definidas después de la primera petición o un Transport construido sin valor en Proxy.

Dónde se usa esta configuración

  • Comprobaciones de precio y disponibilidad desde varios países: un parámetro de país en el nombre de usuario del proxy y rotación por petición para páginas de producto independientes.
  • Clientes de API detrás de una dirección autorizada, cuando un socio ha puesto una IP estática en su lista y el servicio tiene que salir por ella.
  • Seguimiento desde fuera de tu propia red, para que una comprobación de salud vea el sitio como lo ve un visitante de otra región.
  • Workers en una red corporativa que solo llegan a internet a través del proxy de la empresa.

Errores habituales

  • Definir solo HTTP_PROXY. Los destinos HTTPS van directos; define también HTTPS_PROXY.
  • Cambiar el entorno después de la primera petición. net/http lo guarda en caché; reinicia el proceso o usa httpproxy.
  • Esperar rotación con los keep-alive activados. Una conexión reutilizada nunca vuelve a llamar a Proxy.
  • Olvidar resp.Body.Close(). Las conexiones nunca vuelven al pool.
  • No poner Client.Timeout. Una salida silenciosa bloquea una goroutine para siempre.
  • Pegar una contraseña con @ o : en la cadena de la URL. Constrúyela con url.UserPassword.
  • Definir a la vez Transport.Proxy y un DialContext SOCKS5. Elige uno.
  • Reintentar un 407. El segundo intento envía las mismas credenciales incorrectas.

¿Qué enfoque conviene elegir?

Tu situaciónRecomendación
Un proxy, sin cambiar el códigoHTTPS_PROXY y HTTP_PROXY, NO_PROXY para los hosts internos
Un proxy, definido en el códigoTransport{Proxy: http.ProxyURL(u)} más Client.Timeout
Una salida nueva en cada peticiónTu propia función Proxy o un gateway rotativo, DisableKeepAlives: true
La misma salida para un flujo con inicio de sesiónUn parámetro de sesión en el nombre de usuario, keep-alive activado
SOCKS5 con DNS en el proxysocks5://user:pass@host:port en Transport.Proxy
SOCKS5 para una conexión que no es HTTPDialer de golang.org/x/net/proxy
Muchos workers sobre un mismo sitioMaxConnsPerHost y MaxIdleConnsPerHost = número de workers
Proxy corporativo con CA propiaLa CA en tls.Config.RootCAs, nunca InsecureSkipVerify

Preguntas frecuentes

¿http.Get en Go usa un proxy automáticamente?

Sí, cuando el entorno lo indica: http.Get usa http.DefaultClient, cuyo Transport pone http.ProxyFromEnvironment en Proxy. Un Transport que creas tú empieza sin proxy.

¿Es lo mismo que GOPROXY?

No. GOPROXY le dice al comando go de dónde descargar los módulos; no tiene nada que ver con las peticiones que envía tu programa en tiempo de ejecución.

¿Cómo pongo un proxy solo para una petición?

Dale a esa petición su propio Client y Transport, o escribe una función Proxy que devuelva nil para las peticiones que deben ir directas. Puede que ya exista una conexión en el pool hacia el mismo host; req.Close = true fuerza una nueva.

¿Go admite socks5h?

Sí, y socks5 se comporta igual: Go siempre envía el nombre de host al proxy, que lo resuelve; net/http no tiene un modo SOCKS5 con resolución local.

¿Por qué localhost nunca pasa por el proxy?

ProxyFromEnvironment no devuelve proxy para localhost ni para las direcciones de loopback, digan lo que digan las variables; para pasar esas peticiones por el proxy, usa http.ProxyURL.

¿Cómo veo qué proxy usó una petición?

Envuelve la función Proxy y registra u.Redacted(), o define Transport.OnProxyConnectResponse, que recibe la URL del proxy y la respuesta al CONNECT. Con los keep-alive activados, la elección se registra solo cuando se abre una conexión nueva.

En resumen

En Go, el proxy es una función de http.Transport: http.ProxyURL para una dirección fija, http.ProxyFromEnvironment para las variables estándar o tu propia función para elegir por petición. Pon user:pass en la URL y Go envía Proxy-Authorization por su cuenta; usa socks5:// para SOCKS5; define Client.Timeout y los límites del pool; reintenta 429 y 5xx con backoff, pero nunca un 407; desactiva los keep-alive cuando cada petición necesite una salida nueva. Los endpoints residenciales, ISP estáticos, de centro de datos y SOCKS5 que funcionan con estos ajustes están en la página de proxies de Proxynet.