Base de connaissances

Comment utiliser des proxys résidentiels en Go avec net/http et Colly

Les proxys en Go : configuration du transport, les pièges du drainage du body et de MaxIdleConnsPerHost qui tuent la réutilisation de connexions, rotation géo par requête, et SetProxyFunc de Colly.

Chris Collins

Chris Collins

24 juillet 2026 · 10 min de lecture

Go convient naturellement au scraping : concurrence bon marché, une bibliothèque standard solide, et un seul binaire statique à déployer. Brancher un proxy résidentiel dans net/http fait véritablement trois lignes. Ce qui coûte une journée aux gens, c’est tout ce qui vient après ces trois lignes, parce que le client HTTP de Go a quelques arêtes vives qui détruisent silencieusement la réutilisation des connexions, et à travers un proxy cela devient de la latence et des timeouts difficiles à expliquer.

Ceci est l’entrée Go de la même série que proxys résidentiels avec Python et avec Playwright : le code qui marche, plus les pièges spécifiques qui comptent en Go.

Tout ci-dessous utilise le gateway résidentiel de Shifter : un point de terminaison, p.shifter.io:443, avec tout le ciblage encodé dans le nom d’utilisateur. Changez l’hôte et les identifiants pour un autre fournisseur ; la forme est la même.

Le modèle du gateway en un paragraphe

Le nom d’utilisateur du proxy porte votre authentification et votre ciblage. Vous ne changez pas de point de terminaison pour changer de pays ou de session, vous changez la chaîne du nom d’utilisateur :

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

country-us cible les États-Unis, sid fixe une session sticky, ttl maintient cette IP pendant N secondes. Omettez sid/ttl et chaque nouvelle connexion tourne. Le mot de passe est constant.

Contrairement à Chromium (qui ignore les identifiants de proxy en ligne), le net/http de Go gère correctement les identifiants dans l’URL du proxy, en envoyant Proxy-Authorization pour vous. Donc http://user:pass@host:port fonctionne tout simplement.

La version minimale

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)) // une IP résidentielle américaine
}

C’est toute l’intégration. Maintenant les parties qui décident de sa performance.

Piège 1 : réutilisez le client, n’en créez jamais un par requête

C’est l’erreur Go la plus fréquente et la plus coûteuse, et un proxy l’aggrave.

http.Client et http.Transport sont conçus pour être durables et partagés. Ils sont sûrs pour un usage concurrent par plusieurs goroutines, et le pool de connexions vit dans le Transport. Créez un nouveau client par requête et chaque requête paie un handshake TCP frais plus un handshake TLS à travers le proxy, exactement le surcoût que le guide de latence dit d’éliminer. Pire, vous fuitez des connexions inactives.

// MAUVAIS : un nouveau client par requête. Zéro réutilisation, fuite de connexions.
func fetch(u string) (*http.Response, error) {
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
return client.Get(u)
}
// BON : un client, au niveau du package ou injecté, partagé entre goroutines.
var client = &http.Client{ /* configuré une fois, voir ci-dessous */ }

Construisez-le une fois au démarrage et faites-le circuler.

Piège 2 : drainez et fermez le body, sinon pas de réutilisation

Go ne rendra une connexion au pool que si le body de la réponse est entièrement lu et fermé. Fermez-le sans le lire, et la connexion est jetée, donc vous perdez silencieusement le pooling même si votre client est partagé.

resp, err := client.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
// Si vous n'avez pas besoin du body, drainez-le quand même pour que la connexion
// puisse être réutilisée. Les appels différés s'exécutent en dernier-entré-premier-sorti,
// donc ce drainage s'exécute *avant* le Close ci-dessus, ce qui est l'ordre voulu.
defer io.Copy(io.Discard, resp.Body)

Si vous parsez le body normalement (io.ReadAll, un décodeur JSON qui lit jusqu’à EOF), tout va bien. Le piège, ce sont les retours anticipés : abandonner sur un statut non-200 sans drainer laisse la connexion inutilisable, et sur un scraper qui rencontre beaucoup de blocages, c’est l’essentiel de votre trafic.

Piège 3 : MaxIdleConnsPerHost vaut 2 par défaut

Celui-ci mord chaque scraper Go. Le MaxIdleConnsPerHost par défaut de http.Transport est 2. Lancez 50 goroutines contre un hôte et 48 d’entre elles ouvrent et jettent sans cesse des connexions fraîches, chacune payant un handshake complet à travers le proxy.

Réglez-le au moins à votre concurrence par hôte :

transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50, // >= votre concurrence par hôte
IdleConnTimeout: 90 * time.Second,
// Des timeouts granulaires valent mieux qu'un Client.Timeout grossier.
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{Transport: transport}

Notez qu’il n’y a pas de Client.Timeout ici, volontairement. Client.Timeout est un budget total unique couvrant dial, TLS, en-têtes, et body. Les timeouts granulaires du transport vous laissent distinguer un connect lent d’une réponse lente, exactement la distinction connect-versus-read qui rend les timeouts diagnosticables. Utilisez context.WithTimeout par requête pour une échéance globale.

Faire tourner géo et sessions

Parce que le ciblage vit dans le nom d’utilisateur, une identité différente signifie une URL de proxy différente. Deux approches :

Par requête, via la fonction Proxy. Transport.Proxy est appelée à chaque requête, vous pouvez donc y varier l’identité. Go poole les connexions par URL de proxy, donc ça reste efficace :

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")))
}
// Lisez le ciblage depuis le contexte de la requête pour que chaque job choisisse son identité.
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,
}

Puis attachez une identité par job :

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

Ou un transport par identité, mis en cache dans une map, quand vous avez un petit ensemble fixe (un par pays, disons). L’un ou l’autre marche ; l’approche par contexte scale mieux quand l’identité est par élément de travail.

Donnez à chaque unité logique de travail son propre sid et tournez entre unités plutôt qu’en milieu de flux (sticky vs rotatif couvre quand chacun s’applique, et le billet sur la répartition de charge couvre le mappage du travail aux identités).

Concurrence, par hôte

Go rend trivial le lancement de 10 000 goroutines, et tout aussi trivial de se faire bloquer. Bornez la concurrence par hôte cible, pas globalement, pour qu’une cible fragile ne soit pas martelée et qu’une cible permissive ne soit pas bridée :

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)
// ... effectuer la requête
return nil
}

Au-delà de la tolérance d’une cible, plus de parallélisme achète des blocages, pas du débit (comment éviter de se faire bloquer).

Colly

Colly est le framework de scraping standard en Go, et il prend un proxy en une ligne :

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

Pour de la géo ou des sessions sticky par requête, utilisez SetProxyFunc, la même idée que la fonction Transport.Proxy ci-dessus :

c.SetProxyFunc(func(r *http.Request) (*url.URL, error) {
// Choisir pays/session par requête, p. ex. depuis l'URL ou une map de jobs.
return proxyFor("de", "job-42")
})

Colly a son propre limiteur par domaine, utilisez-le plutôt que d’en bricoler un :

c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 8,
Delay: 200 * time.Millisecond,
RandomDelay: 200 * time.Millisecond, // du jitter, pour que les requêtes ne soient pas un métronome
})

Et posez un transport personnalisé quand vous voulez les réglages de pooling et de timeouts vus plus haut :

c.WithTransport(transport)

Le OnError de Colly est l’endroit pour la logique de réessai. Classez avant de réessayer, et changez d’identité sur les blocages plutôt que de réessayer la même sortie, la table de classification du billet sur la répartition de charge s’applique directement.

Vérifiez que vous êtes bien sur le proxy

Avant de benchmarker ou de déboguer quoi que ce soit d’autre, confirmez l’IP de sortie :

resp, _ := client.Get("http://ip-api.com/json")
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b)) // attendez une IP résidentielle dans le pays ciblé

Votre propre IP signifie que le client n’utilise pas le proxy. Un blocage signifie que la sortie locale est bloquée. Les deux sont couverts dans le guide de diagnostic des timeouts.

FAQ

Go supporte-t-il les identifiants de proxy dans l’URL ? Oui. Contrairement aux navigateurs basés sur Chromium, le net/http de Go gère correctement http://user:pass@host:port et envoie Proxy-Authorization pour vous. Comme le gateway encode le ciblage dans le nom d’utilisateur, c’est tout ce dont vous avez besoin.

Pourquoi mon scraper Go est-il lent à travers un proxy même avec un seul client ? Le plus probable : vous ne drainez pas les bodies des réponses (donc les connexions ne retournent jamais au pool), ou MaxIdleConnsPerHost est encore à 2 par défaut alors que vous lancez de nombreuses goroutines. Corrigez les deux et le surcoût de handshake par requête disparaît largement.

Comment faire tourner les IP par requête en Go ? Variez le nom d’utilisateur du proxy. Soit vous renvoyez une URL différente depuis Transport.Proxy (en lisant l’identité dans le contexte de la requête), soit vous gardez un transport par identité. Go poole les connexions par URL de proxy, donc la variation par requête reste efficace.

Devrais-je utiliser Client.Timeout ou les timeouts du transport ? Les deux, pour des rôles différents. Les timeouts granulaires du transport (TLSHandshakeTimeout, ResponseHeaderTimeout) vous laissent distinguer un connect lent d’une réponse lente ; utilisez context.WithTimeout par requête pour l’échéance globale. Un seul Client.Timeout grossier cache quelle phase a échoué.

Puis-je utiliser http.ProxyFromEnvironment à la place ? Oui, il lit HTTP_PROXY/HTTPS_PROXY/NO_PROXY, ce qui est pratique en conteneurs. C’est un bon défaut quand toutes les requêtes utilisent la même identité, mais vous ne pouvez pas varier géo ou session par requête ainsi, alors utilisez une fonction Proxy explicite quand vous avez besoin de ciblage.

En résumé

Go plus proxys résidentiels est une combinaison solide dès lors que vous respectez les règles de la bibliothèque standard : partagez un seul client durable, drainez et fermez toujours les bodies, montez MaxIdleConnsPerHost au niveau de votre concurrence, et préférez les timeouts granulaires du transport à un total grossier. Variez le nom d’utilisateur du proxy pour changer géo ou session, bornez la concurrence par hôte, et laissez le limiteur de Colly et SetProxyFunc faire le même travail au niveau du framework.

Réussissez cela et l’intégration de trois lignes performe comme Go devrait. Pointez-la vers le gateway résidentiel, et souvenez-vous que la qualité du pool décide à quelle fréquence vous réessayez tout court (réputation d’IP). La page tarifs propose les forfaits au Go pour le tester contre vos propres cibles.

Prêt à commencer ?

Essayez les proxies résidentiels de Shifter, 205M+ IPs, 195+ pays, à partir de $0.75/GB.

Commencer