Conhecimento

Como Usar Proxies Residenciais em Go com net/http e Colly

Proxies em Go: configuração de transport, as pegadinhas do body-drain e MaxIdleConnsPerHost que matam a reutilização de conexão, rotação geográfica por requisição e o SetProxyFunc do Colly.

Chris Collins

Chris Collins

24 de julho de 2026 · 10 min de leitura

Go é uma escolha natural para scraping: concorrência barata, uma biblioteca padrão sólida e um único binário estático para implantar. Conectar um proxy residencial ao net/http é, de fato, questão de três linhas. A parte que custa um dia inteiro de trabalho é tudo o que vem depois dessas três linhas, porque o cliente HTTP do Go tem algumas arestas afiadas que destroem silenciosamente o reaproveitamento de conexões, e, através de um proxy, isso se transforma em latência e timeouts difíceis de explicar.

Este é o capítulo de Go da mesma série de proxies residenciais com Python e com Playwright: o código funcional, além das armadilhas específicas que importam em Go.

Tudo abaixo usa o gateway residencial da Shifter: um único endpoint, p.shifter.io:443, com toda a segmentação codificada no nome de usuário. Troque o host e as credenciais para usar outro provedor; a estrutura é a mesma.

O modelo de gateway em um parágrafo

O nome de usuário do proxy carrega sua autenticação e sua segmentação. Você não troca de endpoint para mudar de país ou sessão, você muda a string do nome de usuário:

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

country-us direciona para os EUA, sid fixa uma sessão persistente, ttl mantém esse IP por N segundos. Omita sid/ttl e cada nova conexão realiza rotação. A senha é constante.

Diferente do Chromium (que ignora credenciais de proxy embutidas na URL), o net/http do Go trata corretamente as credenciais na URL do proxy, enviando o Proxy-Authorization para você. Então http://user:pass@host:port simplesmente funciona.

A versão 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)) // um IP residencial dos EUA
}

Essa é toda a integração. Agora, as partes que decidem se ela vai performar.

Armadilha 1: reaproveite o client, nunca crie um por requisição

Este é o erro mais comum e mais custoso em Go, e um proxy piora as coisas.

http.Client e http.Transport são projetados para ter vida longa e serem compartilhados. São seguros para uso concorrente por múltiplas goroutines, e o pool de conexões vive dentro do Transport. Crie um novo client por requisição e cada requisição paga um handshake TCP novo além de um handshake TLS através do proxy, exatamente a sobrecarga que o guia de latência recomenda eliminar. Pior, você vaza conexões ociosas.

// RUIM: um novo client por requisição. Zero reaproveitamento de conexão, vaza conexões.
func fetch(u string) (*http.Response, error) {
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
return client.Get(u)
}
// BOM: um único client, em nível de pacote ou injetado, compartilhado entre goroutines.
var client = &http.Client{ /* configurado uma vez, veja abaixo */ }

Construa-o uma vez na inicialização e o passe adiante.

Armadilha 2: drene e feche o body, ou você não terá reaproveitamento

O Go só devolve uma conexão ao pool se o corpo da resposta for totalmente lido e fechado. Feche-o sem ler, e a conexão é descartada, então você perde silenciosamente o pooling mesmo com um client compartilhado.

resp, err := client.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
// Se você não precisa do body, drene-o mesmo assim para que a conexão possa ser reaproveitada.
// Chamadas com defer executam na ordem inversa (LIFO), então esse drain roda *antes*
// do Close acima, que é a ordem desejada.
defer io.Copy(io.Discard, resp.Body)

Se você está processando o body normalmente (io.ReadAll, um decoder JSON que lê até o EOF), está tudo bem. A armadilha são os retornos antecipados: sair de um status diferente de 200 sem drenar deixa a conexão inutilizável, e em um scraper que recebe muitos bloqueios, isso representa a maior parte do seu tráfego.

Armadilha 3: MaxIdleConnsPerHost tem valor padrão 2

Essa aqui pega todo scraper em Go. O padrão de MaxIdleConnsPerHost do http.Transport é 2. Execute 50 goroutines contra um único host e 48 delas ficam abrindo e descartando conexões novas, cada uma pagando um handshake completo através do proxy.

Defina esse valor pelo menos igual à sua concorrência por host:

transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50, // >= sua concorrência por host
IdleConnTimeout: 90 * time.Second,
// Timeouts granulares são melhores do que um único Client.Timeout genérico.
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{Transport: transport}

Note que não há Client.Timeout aqui, de propósito. Client.Timeout é um único orçamento total cobrindo dial, TLS, cabeçalhos e body. Os timeouts granulares do transport permitem diferenciar um connect lento de uma resposta lenta, exatamente a distinção entre connect e read que torna os timeouts diagnosticáveis. Use context.WithTimeout por requisição para um prazo geral.

Rotação de geo e sessões

Como a segmentação vive no nome de usuário, uma identidade diferente significa uma URL de proxy diferente. Duas abordagens:

Por requisição, via a função Proxy. Transport.Proxy é chamada a cada requisição, então você pode variar a identidade ali. O Go faz pooling de conexões por URL de proxy, então isso permanece 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")))
}
// Leia a segmentação a partir do context da requisição para que cada job escolha sua própria identidade.
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,
}

Depois, anexe uma identidade por job:

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

Ou um transport por identidade, guardado em um map, quando você tem um conjunto pequeno e fixo (um por país, por exemplo). Ambos funcionam; a abordagem via context escala melhor quando a identidade é por item de trabalho.

Dê a cada unidade lógica de trabalho seu próprio sid e faça a rotação entre unidades em vez de no meio do fluxo (o post sticky vs rotating cobre quando cada uma se aplica, e o post de balanceamento de carga cobre o mapeamento de trabalho para identidades).

Concorrência, por host

O Go torna trivial lançar 10.000 goroutines, e igualmente trivial ser bloqueado. Limite a concorrência por host de destino, não globalmente, para que um alvo frágil não seja martelado e um alvo permissivo não seja limitado sem necessidade:

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)
// ... faça a requisição
return nil
}

Além da tolerância de um alvo, mais paralelismo compra bloqueios, não throughput (como evitar ser bloqueado).

Colly

O Colly é o framework de scraping padrão em Go, e ele aceita um proxy em uma linha:

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 ou sessões persistentes por requisição, use SetProxyFunc, que segue a mesma ideia da função Transport.Proxy mostrada acima:

c.SetProxyFunc(func(r *http.Request) (*url.URL, error) {
// Escolha país/sessão por requisição, por exemplo a partir da URL ou de um map de jobs.
return proxyFor("de", "job-42")
})

O Colly tem seu próprio limitador por domínio, use-o em vez de criar o seu próprio:

c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 8,
Delay: 200 * time.Millisecond,
RandomDelay: 200 * time.Millisecond, // jitter, para que as requisições não sejam um metrônomo
})

E defina um transport customizado quando quiser as configurações de pooling e timeout vistas anteriormente:

c.WithTransport(transport)

O OnError do Colly é o lugar certo para a lógica de retry. Classifique antes de tentar novamente, e mude de identidade em caso de bloqueios em vez de tentar novamente na mesma saída; a tabela de classificação do post de balanceamento de carga se aplica diretamente.

Verifique se você está realmente usando o proxy

Antes de fazer qualquer benchmark ou depuração, confirme o IP de saída:

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

Ver seu próprio IP significa que o client não está usando o proxy. Um travamento significa que a saída local está bloqueada. Ambos os casos são abordados no guia de diagnóstico de timeout.

Perguntas frequentes

O Go suporta credenciais de proxy na URL? Sim. Diferente dos navegadores baseados em Chromium, o net/http do Go trata corretamente http://user:pass@host:port e envia o Proxy-Authorization para você. Como o gateway codifica a segmentação no nome de usuário, isso é tudo o que você precisa.

Por que meu scraper em Go está lento através de um proxy mesmo com um único client? O mais provável é que você não esteja drenando os corpos das respostas (então as conexões nunca voltam ao pool), ou que MaxIdleConnsPerHost ainda esteja no padrão de 2 enquanto você executa muitas goroutines. Corrija os dois e a maior parte da sobrecarga de handshake por requisição desaparece.

Como faço rotação de IPs por requisição em Go? Varie o nome de usuário do proxy. Ou retorne uma URL diferente a partir de Transport.Proxy (lendo a identidade a partir do context da requisição), ou mantenha um transport por identidade. O Go faz pooling de conexões por URL de proxy, então a variação por requisição continua eficiente.

Devo usar Client.Timeout ou timeouts de transport? Ambos, para tarefas diferentes. Timeouts granulares de transport (TLSHandshakeTimeout, ResponseHeaderTimeout) permitem distinguir um connect lento de uma resposta lenta; use context.WithTimeout por requisição para o prazo geral. Um único Client.Timeout genérico esconde qual fase falhou.

Posso usar http.ProxyFromEnvironment em vez disso? Sim, ele lê HTTP_PROXY/HTTPS_PROXY/NO_PROXY, o que é útil em containers. É uma boa opção padrão quando toda requisição usa a mesma identidade, mas você não consegue variar geo ou sessão por requisição dessa forma, então use uma função Proxy explícita quando precisar de segmentação.

Conclusão

Go combinado com proxies residenciais é uma combinação forte assim que você respeita as regras da biblioteca padrão: compartilhe um único client de vida longa, sempre drene e feche os bodies, aumente MaxIdleConnsPerHost para corresponder à sua concorrência, e prefira timeouts granulares de transport a um único timeout total genérico. Varie o nome de usuário do proxy para mudar geo ou sessão, limite a concorrência por host, e deixe o limitador do Colly e o SetProxyFunc fazerem o mesmo trabalho no nível do framework.

Acerte esses pontos e a integração de três linhas vai performar do jeito que o Go deveria. Aponte-a para o gateway residencial, e lembre-se de que a qualidade do pool decide com que frequência você vai precisar tentar de novo (reputação de IP). A página de preços tem os planos por GB para você testar contra seus próprios alvos.

Pronto para começar?

Experimente os proxies residenciais da Shifter, mais de 205M IPs, mais de 195 países, a partir de $0,75/GB.

Começar