Conocimiento

Cómo usar proxies residenciales en Go con net/http y Colly

Proxies en Go: configuración del transport, los gotchas de drenar el body y MaxIdleConnsPerHost que matan la reutilización de conexiones, rotación geo por petición, y SetProxyFunc de Colly.

Chris Collins

Chris Collins

24 de julio de 2026 · 10 min de lectura

Go encaja de forma natural con el scraping: concurrencia barata, una librería estándar sólida, y un único binario estático que desplegar. Conectar un proxy residencial a net/http son genuinamente tres líneas. La parte que le cuesta un día a la gente es todo lo que viene después de esas tres líneas, porque el cliente HTTP de Go tiene unas cuantas aristas que destruyen en silencio la reutilización de conexiones, y a través de un proxy eso se convierte en latencia y timeouts que te costará explicar.

Esta es la entrada de Go de la misma serie que proxies residenciales con Python y con Playwright: el código que funciona, más las trampas concretas que importan en Go.

Todo lo de abajo usa el gateway residencial de Shifter: un endpoint, p.shifter.io:443, con todo el targeting codificado en el nombre de usuario. Cambia host y credenciales para otro proveedor; la forma es la misma.

El modelo del gateway en un párrafo

El nombre de usuario del proxy lleva tu autenticación y tu targeting. No cambias de endpoint para cambiar de país o sesión, cambias la cadena del nombre de usuario:

customer-USERNAME-country-us-sid-abc123-ttl-600

country-us apunta a EE. UU., sid fija una sesión sticky, ttl mantiene esa IP durante N segundos. Omite sid/ttl y cada nueva conexión rota. La contraseña es constante.

A diferencia de Chromium (que ignora las credenciales de proxy en línea), el net/http de Go maneja correctamente las credenciales en la URL del proxy, enviando Proxy-Authorization por ti. Así que http://user:pass@host:port simplemente funciona.

La versión mínima

package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
func main() {
user, pass := os.Getenv("SHIFTER_USER"), os.Getenv("SHIFTER_PASS")
proxyURL, err := url.Parse(fmt.Sprintf("http://%s-country-us:%s@p.shifter.io:443", user, pass))
if err != nil {
panic(err)
}
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 30 * time.Second,
}
resp, err := client.Get("https://api.ipify.org")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body)) // una IP residencial de EE. UU.
}

Esa es toda la integración. Ahora las partes que deciden si rinde.

Gotcha 1: reutiliza el cliente, nunca crees uno por petición

Este es el error de Go más común y más caro, y un proxy lo empeora.

http.Client y http.Transport están diseñados para ser de larga vida y compartidos. Son seguros para uso concurrente desde múltiples goroutines, y el pool de conexiones vive en el Transport. Crea un cliente nuevo por petición y cada petición paga un handshake TCP fresco más un handshake TLS a través del proxy, que es exactamente el overhead que la guía de latencia dice que elimines. Peor aún, filtras conexiones inactivas.

// MAL: un cliente nuevo por petición. Cero reutilización, filtra conexiones.
func fetch(u string) (*http.Response, error) {
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
return client.Get(u)
}
// BIEN: un cliente, a nivel de paquete o inyectado, compartido entre goroutines.
var client = &http.Client{ /* configurado una vez, ve abajo */ }

Constrúyelo una vez al arrancar y pásalo por ahí.

Gotcha 2: drena y cierra el body, o no obtienes reutilización

Go solo devolverá una conexión al pool si el body de la respuesta se lee por completo y se cierra. Ciérralo sin leerlo, y la conexión se descarta, así que pierdes el pooling en silencio aunque tu cliente esté compartido.

resp, err := client.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
// Si no necesitas el body, drénalo igualmente para que la conexión se pueda reutilizar.
// Las llamadas diferidas corren last-in-first-out, así que este drenaje corre *antes*
// del Close de arriba, que es el orden que quieres.
defer io.Copy(io.Discard, resp.Body)

Si estás parseando el body normalmente (io.ReadAll, un decoder JSON que lee hasta EOF), estás bien. La trampa son los returns tempranos: salir ante un status distinto de 200 sin drenar deja la conexión inutilizable, y en un scraper que se topa con muchos bloqueos, eso es la mayor parte de tu tráfico.

Gotcha 3: MaxIdleConnsPerHost vale 2 por defecto

Este muerde a todo scraper de Go. El MaxIdleConnsPerHost por defecto de http.Transport es 2. Corre 50 goroutines contra un host y 48 de ellas siguen abriendo y descartando conexiones frescas, cada una pagando un handshake completo a través del proxy.

Ponlo al menos a tu concurrencia por host:

transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50, // >= tu concurrencia por host
IdleConnTimeout: 90 * time.Second,
// Los timeouts granulares le ganan a un Client.Timeout romo.
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{Transport: transport}

Nota que aquí no hay Client.Timeout a propósito. Client.Timeout es un único presupuesto total que cubre dial, TLS, headers, y body. Los timeouts granulares del transport te dejan distinguir un connect lento de una respuesta lenta, que es exactamente la distinción connect-versus-read que hace los timeouts diagnosticables. Usa context.WithTimeout por petición para un deadline global.

Rotar geo y sesiones

Como el targeting vive en el nombre de usuario, una identidad distinta significa una URL de proxy distinta. Dos enfoques:

Por petición, vía la func Proxy. Transport.Proxy se llama por petición, así que puedes variar la identidad ahí. Go agrupa conexiones por URL de proxy, así que esto sigue siendo eficiente:

func proxyFor(country, sid string) (*url.URL, error) {
u := fmt.Sprintf("%s-country-%s", os.Getenv("SHIFTER_USER"), country)
if sid != "" {
u += fmt.Sprintf("-sid-%s-ttl-600", sid)
}
return url.Parse(fmt.Sprintf("http://%s:%s@p.shifter.io:443", u, os.Getenv("SHIFTER_PASS")))
}
// Lee el targeting del contexto de la petición para que cada job elija su identidad.
type ctxKey string
const identityKey ctxKey = "identity"
type identity struct{ Country, SID string }
transport := &http.Transport{
Proxy: func(r *http.Request) (*url.URL, error) {
if id, ok := r.Context().Value(identityKey).(identity); ok {
return proxyFor(id.Country, id.SID)
}
return proxyFor("us", "")
},
MaxIdleConnsPerHost: 50,
}

Luego adjunta una identidad por job:

ctx := context.WithValue(req.Context(), identityKey, identity{Country: "de", SID: "job-42"})
resp, err := client.Do(req.WithContext(ctx))

O un transport por identidad, cacheado en un map, cuando tienes un conjunto fijo pequeño (uno por país, digamos). Cualquiera funciona; el enfoque de contexto escala mejor cuando la identidad es por ítem de trabajo.

Dale a cada unidad lógica de trabajo su propio sid y rota entre unidades en lugar de a mitad de flujo (sticky vs rotating cubre cuándo aplica cada uno, y el post de balanceo de carga cubre mapear trabajo a identidades).

Concurrencia, por host

Go hace trivial lanzar 10.000 goroutines, e igual de trivial que te bloqueen. Acota la concurrencia por host objetivo, no globalmente, para que un objetivo frágil no pueda ser martillado y uno permisivo no quede limitado:

import "golang.org/x/sync/semaphore"
var limits = map[string]*semaphore.Weighted{
"tough-site.example": semaphore.NewWeighted(4),
"open-site.example": semaphore.NewWeighted(32),
}
func fetch(ctx context.Context, host, u string) error {
sem := limits[host]
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
// ... haz la petición
return nil
}

Pasada la tolerancia de un objetivo, más paralelismo compra bloqueos, no throughput (cómo evitar que te bloqueen).

Colly

Colly es el framework de scraping estándar de Go, y acepta un proxy en una línea:

c := colly.NewCollector(colly.AllowedDomains("example.com"))
proxyURL := fmt.Sprintf("http://%s-country-us:%s@p.shifter.io:443", user, pass)
if err := c.SetProxy(proxyURL); err != nil {
log.Fatal(err)
}

Para geo o sesiones sticky por petición, usa SetProxyFunc, que es la misma idea que la func Transport.Proxy de arriba:

c.SetProxyFunc(func(r *http.Request) (*url.URL, error) {
// Elige país/sesión por petición, p. ej. desde la URL o un map de jobs.
return proxyFor("de", "job-42")
})

Colly tiene su propio limitador por dominio, úsalo en lugar de montar el tuyo:

c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 8,
Delay: 200 * time.Millisecond,
RandomDelay: 200 * time.Millisecond, // jitter, para que las peticiones no sean un metrónomo
})

Y pon un transport propio cuando quieras los ajustes de pooling y timeouts de antes:

c.WithTransport(transport)

El OnError de Colly es donde va la lógica de reintentos. Clasifica antes de reintentar, y cambia de identidad ante bloqueos en lugar de reintentar la misma salida, la tabla de clasificación del post de balanceo de carga aplica directamente.

Verifica que de verdad estás en el proxy

Antes de hacer benchmark o depurar cualquier otra cosa, confirma la IP de salida:

resp, _ := client.Get("http://ip-api.com/json")
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b)) // espera una IP residencial en el país objetivo

Tu propia IP significa que el cliente no está usando el proxy. Un cuelgue significa que la salida local está bloqueada. Ambos están cubiertos en la guía de diagnóstico de timeouts.

Preguntas frecuentes

¿Soporta Go las credenciales de proxy en la URL? Sí. A diferencia de los navegadores basados en Chromium, el net/http de Go maneja http://user:pass@host:port correctamente y envía Proxy-Authorization por ti. Como el gateway codifica el targeting en el nombre de usuario, eso es todo lo que necesitas.

¿Por qué mi scraper de Go es lento a través de un proxy incluso con un solo cliente? Lo más probable es que no estés drenando los bodies de las respuestas (así que las conexiones nunca vuelven al pool), o que MaxIdleConnsPerHost siga siendo el 2 por defecto mientras corres muchas goroutines. Arregla ambos y el overhead de handshake por petición desaparece en gran medida.

¿Cómo roto IPs por petición en Go? Varía el nombre de usuario del proxy. O devuelves una URL distinta desde Transport.Proxy (leyendo la identidad del contexto de la petición) o mantienes un transport por identidad. Go agrupa conexiones por URL de proxy, así que la variación por petición sigue siendo eficiente.

¿Debería usar Client.Timeout o los timeouts del transport? Ambos, para trabajos distintos. Los timeouts granulares del transport (TLSHandshakeTimeout, ResponseHeaderTimeout) te dejan distinguir un connect lento de una respuesta lenta; usa context.WithTimeout por petición para el deadline global. Un único Client.Timeout romo esconde qué fase falló.

¿Puedo usar http.ProxyFromEnvironment en su lugar? Sí, lee HTTP_PROXY/HTTPS_PROXY/NO_PROXY, lo cual es útil en contenedores. Es un default correcto cuando todas las peticiones usan la misma identidad, pero así no puedes variar geo ni sesión por petición, así que usa una func Proxy explícita cuando necesites targeting.

En resumen

Go más proxies residenciales es una combinación fuerte una vez que respetas las reglas de la librería estándar: comparte un único cliente de larga vida, drena y cierra siempre los bodies, sube MaxIdleConnsPerHost para que coincida con tu concurrencia, y prefiere timeouts granulares del transport a un único total romo. Varía el nombre de usuario del proxy para cambiar geo o sesión, acota la concurrencia por host, y deja que el limitador de Colly y SetProxyFunc hagan el mismo trabajo a nivel de framework.

Acierta eso y la integración de tres líneas rinde como Go debería. Apúntala al gateway residencial, y recuerda que la calidad del pool decide con qué frecuencia estás reintentando siquiera (reputación de IP). La página de precios tiene los planes por GB para probarlo contra tus propios objetivos.

¿Listo para empezar?

Prueba los proxies residenciales de Shifter, más de 205M IPs, más de 195 países, desde 0,75 $/GB.

Comenzar