Guía completa de proxy HTTP en Rust: reqwest, hyper y rotación de IPs

Aprende a configurar proxies HTTP en Rust con reqwest, hyper y tokio. Incluye rotación de IPs, TLS, concurrencia y manejo de errores para scraping de alto rendimiento.

Guía completa de proxy HTTP en Rust: reqwest, hyper y rotación de IPs

Por qué necesitas proxies HTTP en Rust

Si estás construyendo infraestructura de scraping en Rust, tarde o temprano te enfrentarás a bloqueos por IP, rate limiting y CAPTCHAs. Los Rust residential proxies son tu primera línea de defensa: enrutan tus peticiones a través de IPs reales de ISP, haciendo que tu tráfico sea indistinguible del de un usuario normal.

Rust brilla aquí por su modelo de concurrencia sin runtime pesado y su seguridad en tiempo de compilación. Pero configurar proxies correctamente —con autenticación, TLS, rotación y manejo de errores— requiere conocer las entrañas de reqwest, hyper y el ecosistema tokio.

En esta guía cubriremos desde la configuración básica de reqwest proxy hasta una abstracción de pool rotatorio lista para producción, pasando por hyper de bajo nivel, concurrencia con JoinSet, y la decisión entre rustls y native-tls.

Configurar reqwest con proxy, autenticación y TLS personalizado

reqwest es el cliente HTTP de facto en Rust. Soporta proxies HTTP/SOCKS5 de forma nativa cuando activas las feature flags correctas.

Dependencias en Cargo.toml

[dependencies]
reqwest = { version = "0.12", features = ["proxy", "rustls-tls"], default-features = false }
tokio = { version = "1", features = ["full"] }

Cliente con proxy autenticado y geo-targeting

ProxyHat permite pasar el país y la ciudad directamente en el campo del usuario. Esto es ideal para Rust residential proxies con geo-targeting preciso:

use reqwest::Proxy;
use std::error::Error;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    // Geo-targeting: país + ciudad en el username
    let proxy_url = "http://user-country-DE-city-berlin:PASSWORD@gate.proxyhat.com:8080";
    let proxy = Proxy::all(proxy_url)?;

    let client = reqwest::Client::builder()
        .proxy(proxy)
        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
        .timeout(std::time::Duration::from_secs(30))
        .build()?;

    let resp = client
        .get("https://httpbin.org/ip")
        .send()
        .await?;

    let body = resp.text().await?;
    println!("IP detectada: {}", body);
    Ok(())
}

Sesión sticky con reqwest

Para scraping secuencial donde necesitas la misma IP durante múltiples peticiones, usa el flag session en el username:

use reqwest::Proxy;
use std::error::Error;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let proxy_url = "http://user-country-US-session-myid123:PASSWORD@gate.proxyhat.com:8080";
    let proxy = Proxy::all(proxy_url)?;

    let client = reqwest::Client::builder()
        .proxy(proxy)
        .cookie_store(true)
        .build()?;

    // Todas las peticiones con este client usan la misma IP de salida
    for page in 1..=5 {
        let resp = client
            .get(format!("https://example.com/page/{}", page))
            .send()
            .await?;
        println!("Página {}: Status {}", page, resp.status());
    }
    Ok(())
}

hyper de bajo nivel: CONNECT tunnel para HTTPS

Cuando necesitas control total sobre la conexión, hyper te permite manejar el túnel CONNECT manualmente. Esto es útil cuando reqwest te limita —por ejemplo, para implementar connection pooling personalizado o interceptar el handshake CONNECT.

use hyper::client::connect::proxy::HttpConnector;
use hyper::client::Client;
use hyper::Uri;
use hyper_proxy::{Proxy, ProxyConnector, Intercept};
use hyper_rustls::HttpsConnectorBuilder;
use std::error::Error;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    // Configurar el proxy HTTP
    let proxy_uri: Uri = "http://user-country-FR:PASSWORD@gate.proxyhat.com:8080"
        .parse()?;
    let proxy = Proxy::new(Intercept::All, proxy_uri);

    // Construir el connector con rustls para TLS al destino
    let https = HttpsConnectorBuilder::new()
        .with_native_roots()?
        .https_only()
        .enable_http2()
        .build();

    let proxy_connector = ProxyConnector::from_proxy(https, proxy)?;

    let client: Client<_, hyper::Body> = Client::builder()
        .build(proxy_connector);

    let uri: Uri = "https://httpbin.org/ip".parse()?;
    let resp = client.get(uri).await?;
    println!("Status: {}", resp.status());

    let body_bytes = hyper::body::to_bytes(resp.into_body()).await?;
    println!("Body: {}", String::from_utf8_lossy(&body_bytes));
    Ok(())
}

Nota: hyper-proxy maneja automáticamente el CONNECT tunnel para tráfico HTTPS a través del proxy HTTP. El proxy ve solo el hostname (no el path ni los headers), mientras que el destino ve tu tráfico TLS normal.

Concurrencia con tokio JoinSet para scraping masivo

El patrón JoinSet de tokio es ideal para scraping concurrente: lanza tareas, aborta las que fallen, y recolecta resultados sin deadlocks.

use reqwest::Proxy;
use std::error::Error;
use tokio::task::JoinSet;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let urls = vec![
        "https://httpbin.org/ip",
        "https://httpbin.org/headers",
        "https://httpbin.org/user-agent",
        "https://httpbin.org/get",
        "https://httpbin.org/origin",
    ];

    let mut set: JoinSet<Result<String, reqwest::Error>> = JoinSet::new();

    for url in urls {
        let url = url.to_string();
        set.spawn(async move {
            // Cada tarea obtiene su propio client con IP rotatoria
            let proxy_url = "http://user-country-US:PASSWORD@gate.proxyhat.com:8080";
            let proxy = Proxy::all(proxy_url)?;
            let client = reqwest::Client::builder()
                .proxy(proxy)
                .timeout(std::time::Duration::from_secs(15))
                .build()?;

            let resp = client.get(&url).send().await?;
            resp.text().await
        });
    }

    while let Some(result) = set.join_next().await {
        match result {
            Ok(Ok(body)) => println!("✓ Éxito ({} bytes)", body.len()),
            Ok(Err(e)) => eprintln!("✗ Error de request: {}", e),
            Err(e) => eprintln!("✗ Panic en tarea: {}", e),
        }
    }

    Ok(())
}

Limitar concurrencia con Semaphore

Para no saturar el pool de proxies ni hacer trigger de rate limits, envuelve cada tarea en un Semaphore:

use reqwest::Proxy;
use std::error::Error;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let urls: Vec<String> = (1..=50)
        .map(|i| format!("https://httpbin.org/get?page={}", i))
        .collect();

    let semaphore = Arc::new(Semaphore::new(10)); // máx 10 concurrentes
    let mut set: JoinSet<Result<String, reqwest::Error>> = JoinSet::new();

    for url in urls {
        let sem = semaphore.clone();
        set.spawn(async move {
            let _permit = sem.acquire().await.unwrap();
            let proxy_url = "http://user-country-DE:PASSWORD@gate.proxyhat.com:8080";
            let proxy = Proxy::all(proxy_url)?;
            let client = reqwest::Client::builder()
                .proxy(proxy)
                .timeout(std::time::Duration::from_secs(20))
                .build()?;
            let resp = client.get(&url).send().await?;
            resp.text().await
        });
    }

    let mut ok = 0usize;
    let mut err = 0usize;
    while let Some(result) = set.join_next().await {
        match result {
            Ok(Ok(_)) => ok += 1,
            _ => err += 1,
        }
    }
    println!("Completadas: {} éxitos, {} errores", ok, err);
    Ok(())
}

Rotación de proxies: abstracción con trait

En producción necesitas rotar entre diferentes configuraciones de proxy —distintos países, sesiones, o tipos (residential vs datacenter). Un trait te da flexibilidad para intercambiar estrategias sin cambiar el código de negocio:

use reqwest::{Client, Proxy};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use thiserror::Error;

/// Errores específicos del pool de proxies
#[derive(Debug, Error)]
pub enum ProxyError {
    #[error("Todas las proxies fallaron después de {attempts} intentos")]
    AllFailed { attempts: usize },
    #[error("Error construyendo el cliente: {0}")]
    ClientBuild(String),
}

/// Trait para estrategias de rotación de proxies
#[async_trait::async_trait]
pub trait ProxyRotator: Send + Sync {
    async fn next_client(&self) -> Result<Client, ProxyError>;
}

/// Rotación round-robin sobre configuraciones de ProxyHat
pub struct RoundRobinRotator {
    configs: Vec<String>,
    index: AtomicUsize,
}

impl RoundRobinRotator {
    pub fn new(countries: &[&str]) -> Self {
        let configs: Vec<String> = countries
            .iter()
            .map(|c| format!("http://user-country-{}:PASSWORD@gate.proxyhat.com:8080", c))
            .collect();
        Self {
            configs,
            index: AtomicUsize::new(0),
        }
    }
}

#[async_trait::async_trait]
impl ProxyRotator for RoundRobinRotator {
    async fn next_client(&self) -> Result<Client, ProxyError> {
        let idx = self.index.fetch_add(1, Ordering::Relaxed) % self.configs.len();
        let proxy_url = &self.configs[idx];
        let proxy = Proxy::all(proxy_url.as_str())
            .map_err(|e| ProxyError::ClientBuild(e.to_string()))?;
        Client::builder()
            .proxy(proxy)
            .timeout(std::time::Duration::from_secs(20))
            .build()
            .map_err(|e| ProxyError::ClientBuild(e.to_string()))
    }
}

/// Uso del rotador
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let rotator = Arc::new(RoundRobinRotator::new(&["US", "DE", "FR", "JP"]));

    for i in 0..8 {
        let client = rotator.next_client().await?;
        let resp = client.get("https://httpbin.org/ip").send().await?;
        let body = resp.text().await?;
        println!("Petición {}: {}", i + 1, body.trim());
    }
    Ok(())
}

Este patrón te permite intercambiar RoundRobinRotator por un WeightedRotator o un LeastLatencyRotator sin tocar el código de scraping.

Manejo de errores con thiserror y reintentos

Los proxies fallan: timeouts, 403, conexiones reseteadas. Un buen manejo de errores es la diferencia entre un scraper que se cae a las 3am y uno que corre semanas.

use reqwest::{Client, Proxy, StatusCode};
use std::time::Duration;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ScrapingError {
    #[error("Proxy rechazó la conexión (status {status})")]
    ProxyRejected { status: u16 },
    #[error("Timeout después de {secs}s")]
    Timeout { secs: u64 },
    #[error("Error de red: {0}")]
    Network(#[from] reqwest::Error),
    #[error("Máximos reintentos alcanzados")]
    MaxRetries,
}

/// Reintento con backoff exponencial y rotación de proxy en cada intento
pub async fn fetch_with_retry(
    url: &str,
    proxy_url: &str,
    max_retries: u32,
) -> Result<String, ScrapingError> {
    let mut attempt = 0u32;

    loop {
        attempt += 1;
        let proxy = Proxy::all(proxy_url).map_err(|e| ScrapingError::Network(
            reqwest::Error::new(reqwest::error::Kind::Builder, e)
        ))?;
        let client = Client::builder()
            .proxy(proxy)
            .timeout(Duration::from_secs(15))
            .build()
            .map_err(|e| ScrapingError::Network(e))?;

        match client.get(url).send().await {
            Ok(resp) => {
                let status = resp.status();
                if status == StatusCode::OK {
                    return resp.text().await.map_err(ScrapingError::Network);
                }
                if status.as_u16() == 403 || status.as_u16() == 429 {
                    eprintln!("Intento {}: status {} — reintentando...", attempt, status);
                    if attempt >= max_retries {
                        return Err(ScrapingError::ProxyRejected { status: status.as_u16() });
                    }
                    tokio::time::sleep(Duration::from_millis(500 * 2u64.pow(attempt))).await;
                    continue;
                }
                return Err(ScrapingError::ProxyRejected { status: status.as_u16() });
            }
            Err(e) if e.is_timeout() => {
                eprintln!("Intento {}: timeout", attempt);
                if attempt >= max_retries {
                    return Err(ScrapingError::Timeout { secs: 15 });
                }
                tokio::time::sleep(Duration::from_millis(300 * 2u64.pow(attempt))).await;
                continue;
            }
            Err(e) => {
                eprintln!("Intento {}: error de red — {}", attempt, e);
                if attempt >= max_retries {
                    return Err(ScrapingError::MaxRetries);
                }
                tokio::time::sleep(Duration::from_millis(200 * 2u64.pow(attempt))).await;
                continue;
            }
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let proxy_url = "http://user-country-ES:PASSWORD@gate.proxyhat.com:8080";
    match fetch_with_retry("https://httpbin.org/get", proxy_url, 5).await {
        Ok(body) => println!("Éxito: {} bytes", body.len()),
        Err(e) => eprintln!("Fallo final: {}", e),
    }
    Ok(())
}

rustls vs native-tls: qué elegir para proxies

Rust te obliga a elegir tu backend TLS, y esta decisión afecta directamente a cómo se comportan tus conexiones a través de proxies.

Criteriorustlsnative-tls
Compilación cruzada✅ Fácil (puro Rust)❌ Requiere OpenSSL / SChannel
Tamaño del binarioMenorMayor (vincula C libs)
RendimientoComparable, mejor en ARMBueno en x86 con AES-NI
Compatibilidad PKIRoots de MozillaRoots del SO
HTTP/2Vía rustls + h2Vía native-tls + h2
Certificados corporativos❌ Requiere config manual✅ Hereda del SO
Auditoría de seguridad✅ Sin unsafe, auditado⚠️ Depende de OpenSSL

Recomendación: Para scraping con Rust residential proxies, usa rustls-tls. Compila en cualquier target, produce binarios más pequeños, y evita la pesadilla de las versiones de OpenSSL en contenedores Docker.

Configurar rustls en reqwest

// Cargo.toml:
// reqwest = { version = "0.12", default-features = false, features = ["proxy", "rustls-tls", "json"] }

use reqwest::Proxy;
use std::error::Error;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let proxy = Proxy::all("http://user-country-JP:PASSWORD@gate.proxyhat.com:8080")?;
    let client = reqwest::Client::builder()
        .proxy(proxy)
        .use_rustls_tls() // explícito (ya es default con feature rustls-tls)
        .min_tls_version(reqwest::tls::Version::TLS_1_2)
        .build()?;

    let resp = client.get("https://httpbin.org/ip").send().await?;
    println!("{}", resp.text().await?);
    Ok(())
}

Feature flags en tiempo de compilación

Para bibliotecas que se usan tanto con como sin proxy, las feature flags de Cargo te permiten compilar solo lo necesario:

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", default-features = false, optional = true }
tokio = { version = "1", features = ["full"] }
thiserror = "1"
async-trait = "0.1"

[features]
default = []
proxy = ["reqwest/proxy", "reqwest/rustls-tls"]
socks5 = ["reqwest/socks"]
native-tls = ["reqwest/native-tls"]
full-proxy = ["proxy", "socks5"]

Luego en tu código Rust, usas #[cfg(feature = "proxy")] para compilar condicionalmente:

use reqwest::Client;

pub struct Scraper {
    #[cfg(feature = "proxy")]
    client: Client, // construido con proxy
    #[cfg(not(feature = "proxy"))]
    client: Client, // construido sin proxy
}

impl Scraper {
    #[cfg(feature = "proxy")]
    pub fn new(proxy_url: &str) -> Result<Self, Box<dyn std::error::Error>> {
        let proxy = reqwest::Proxy::all(proxy_url)?;
        let client = Client::builder()
            .proxy(proxy)
            .timeout(std::time::Duration::from_secs(20))
            .build()?;
        Ok(Self { client })
    }

    #[cfg(not(feature = "proxy"))]
    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
        let client = Client::builder()
            .timeout(std::time::Duration::from_secs(20))
            .build()?;
        Ok(Self { client })
    }
}

Esto es especialmente útil cuando publicas una crate que otros consumen: no les fuerzas una dependencia de proxy si no la necesitan.

Consideraciones de producción

Logging y observabilidad

Añade tracing desde el inicio. Cada petición proxy debería loguear al menos: URL destino, país del proxy, status code, latencia, y si hubo retry:

use tracing::{info, warn, error};

// En tu función de fetch:
info!(
    country = %country,
    url = %url,
    status = %resp.status(),
    latency_ms = elapsed.as_millis(),
    "Petición completada"
);

// En errores:
warn!(
    country = %country,
    url = %url,
    attempt = attempt,
    error = %e,
    "Reintentando"
);

Circuit breaker

Si un país o proxy específico devuelve >50% de errores en una ventana de tiempo, déjalo de lado temporalmente. Puedes implementar un circuit breaker simple con un AtomicU32 para contadores de fallo y un timestamp de última apertura.

Rate limiting ético

Respeta robots.txt y los términos de servicio. Usa delays entre peticiones al mismo dominio. Los Rust residential proxies no te hacen invisible — solo más difícil de bloquear. Un comportamiento irrespetuoso eventualmente resultará en bloqueos de subnet.

Concurrencia vs paralelismo

Con tokio, 1000 tareas concurrentes no significan 1000 hilos. Pero sí significan 1000 conexiones TCP abiertas. Ajusta tu pool de conexiones (pool_max_idle_per_host, pool_idle_timeout) y el tamaño del Semaphore según la capacidad de tu proveedor de proxies.

Key Takeaways

Resumen clave:

  • Usa reqwest con feature proxy + rustls-tls para el 90% de los casos de Rust HTTP proxy.
  • Pasa geo-targeting y sesión en el campo username de ProxyHat —no necesitas endpoints diferentes.
  • Usa hyper solo cuando necesites control de bajo nivel sobre el CONNECT tunnel.
  • JoinSet + Semaphore es el patrón canónico para scraping concurrente con rate limiting.
  • Abstrae la rotación con un trait para poder intercambiar estrategias sin tocar la lógica de negocio.
  • Maneja errores con thiserror y reintentos con backoff exponencial —los proxies fallan constantemente.
  • Elige rustls sobre native-tls para contenedores y compilación cruzada.
  • Usa feature flags para que tu crate sea usable con y sin proxy.

Si necesitas Rust residential proxies con geo-targeting por país y ciudad, rotación por petición y sesiones sticky, consulta los planes de ProxyHat. Y para más patrones de scraping, visita nuestra guía de use cases y la página de ubicaciones disponibles.

¿Listo para empezar?

Accede a más de 50M de IPs residenciales en más de 148 países con filtrado impulsado por IA.

Ver preciosProxies residenciales
← Volver al Blog