---
title: "Proxy HTTP en Golang: cómo usar un proxy con net/http"
description: "Usa http.Transport.Proxy con http.ProxyURL o ProxyFromEnvironment y pon user:pass en la URL del proxy. SOCKS5, timeouts, reintentos y rotación en Go 1.26."
url: https://proxynet.io/es/blog/golang-proxy
date: 2026-10-28
author: "Acar Diveroli"
category: "Tutoriales, Integración"
lang: es
---

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

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

> **Nota: Respuesta breve**
>
> Crea un `http.Transport` con `Proxy: http.ProxyURL(u)`, donde `u` es la dirección `http://user:pass@pr.proxynet.io:8000` ya parseada, y dáselo a un `http.Client` con `Timeout`; Go envía el usuario y la contraseña como `Proxy-Authorization` por su cuenta. Para SOCKS5, usa el esquema `socks5://` en la misma URL. `http.Get` usa `http.ProxyFromEnvironment`, así que un programa normal ya respeta `HTTPS_PROXY`, `HTTP_PROXY` y `NO_PROXY`. Para un proxy distinto en cada petición, escribe tu propia función `Proxy` y desactiva los keep-alive, porque una conexión reutilizada no vuelve a consultarla.

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

## ¿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](https://www.rfc-editor.org/rfc/rfc1929), 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](https://proxynet.io/es/socks5-proxy) 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étodo | Dónde va | Alcance | Nota |
|---|---|---|---|
| `http.ProxyURL(u)` | `Transport.Proxy` | ese Transport | un proxy fijo |
| `http.ProxyFromEnvironment` | `Transport.Proxy` (por defecto en `DefaultTransport`) | el proceso | se lee una vez; `localhost` queda fuera |
| tu propia `func(*http.Request)` | `Transport.Proxy` | por petición | rotación, reglas por host |
| URL `socks5://` | cualquiera de las tres anteriores | ese Transport | el DNS se resuelve en el proxy |
| dialer de `x/net/proxy` | `Transport.DialContext` | ese Transport | tambié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 Transport | Llamadas a la función `Proxy` | Túneles abiertos |
|---|---|---|
| Valores por defecto (HTTP/2 negociado) | 1 | 1, solo el primer proxy |
| Solo HTTP/1.1 (mapa `TLSNextProto` vacío) | 4 | 2, uno por proxy, reutilizados |
| `DisableKeepAlives: true` | 4 | 4, 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](https://proxynet.io/es/residential-proxy) 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](https://go-colly.org/docs/examples/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](/es/blog/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ón | Recomendación |
|---|---|
| Un proxy, sin cambiar el código | `HTTPS_PROXY` y `HTTP_PROXY`, `NO_PROXY` para los hosts internos |
| Un proxy, definido en el código | `Transport{Proxy: http.ProxyURL(u)}` más `Client.Timeout` |
| Una salida nueva en cada petición | Tu propia función `Proxy` o un gateway rotativo, `DisableKeepAlives: true` |
| La misma salida para un flujo con inicio de sesión | Un parámetro de sesión en el nombre de usuario, keep-alive activado |
| SOCKS5 con DNS en el proxy | `socks5://user:pass@host:port` en `Transport.Proxy` |
| SOCKS5 para una conexión que no es HTTP | Dialer de `golang.org/x/net/proxy` |
| Muchos workers sobre un mismo sitio | `MaxConnsPerHost` y `MaxIdleConnsPerHost` = número de workers |
| Proxy corporativo con CA propia | La 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](/es/proxy).
