Rust HTTP Proxy: Kompletny przewodnik po reqwest, hyper i rotacji IP

Praktyczny przewodnik po konfiguracji proxy HTTP w Ruście — od reqwest z autoryzacją, przez hyper na niskim poziomie, po współbieżny scraping z rotacją IP i pulą residential proxies.

Rust HTTP Proxy: Kompletny przewodnik po reqwest, hyper i rotacji IP

Dlaczego Rust i proxy HTTP to naturalne połączenie

Rust staje się językiem wyboru dla wysokowydajnej infrastruktury scrapingowej. Bez garbage collectora, z przewidywalnym zarządzaniem pamięcią i natywnym async/await na bazie tokio — oferuje przepustowość, jakiej Python czy Node.js nie potrafią dorównać przy tysiącach jednoczesnych połączeń. Problem? Większość tutoriali pomija warstwę proxy, a bez niej każdy poważny scraper trafia na rate limity, CAPTCHAl i blokady geo.

W tym artykule pokazuję, jak skonfigurować Rust HTTP proxy na każdym poziomie abstrakcji — od wysokopoziomowego reqwest, przez niskopoziomowy hyper, aż po własną abstrakcję puli rotating proxy. Używam Rust residential proxies od ProxyHat, ale wzorce są uniwersalne.

reqwest proxy — szybki start z autoryzacją i TLS

reqwest to de facto standardowy klient HTTP w Ruście. Obsługuje proxy od razu — wystarczy przekazać URL proxy w builderze.

Podstawowa konfiguracja z autoryzacją

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

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    // Proxy z autoryzacją — format ProxyHat
    let proxy_url = "http://myuser-country-US:mypass@gate.proxyhat.com:8080";

    let client = reqwest::Client::builder()
        .proxy(Proxy::all(proxy_url)?)
        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
        .build()?;

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

    println!("Status: {}", resp.status());
    println!("IP: {}", resp.text().await?);
    Ok(())
}

Kluczowe parametry ProxyHat w nazwie użytkownika:

  • country-US — geo-targeting na poziomie kraju
  • country-DE-city-berlin — targeting miasto
  • session-abc123 — sticky session (ten sam IP przez sesję)

Custom TLS z rustls

W Cargo.toml wybierasz backend TLS feature flagą:

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", default-features = false, features = [
    "rustls-tls",     # lub "native-tls"
    "proxy",          # włącza obsługę proxy
    "json",
] }
tokio = { version = "1", features = ["full"] }

rustls daje statyczne linkowanie i brak zależności od OpenSSL — idealne dla kontenerów Alpine i cross-compilation. native-tls używa systemowego TLS (Schannel na Windows, Security.framework na macOS, OpenSSL na Linux). Wybierz rustls, jeśli chcesz powtarzalne buildy; native-tls, jeśli potrzebujesz systemowych certyfikatów enterprise.

hyper z proxy-connect — kontrola na niskim poziomie

Gdy reqwest to za mało — np. potrzebujesz dostępu do warstwy połączenia, custom connection poolingu albo mierzenia czasu DNS — schodzisz do hyper. Proxy HTTPS przez HTTP proxy wymaga metody CONNECT, którą hyper obsługuje przez hyper-proxy.

use hyper::{Client, Uri, Request, Body};
use hyper_proxy::{Proxy, ProxyConnector, Intercept};
use hyper_rustls::HttpsConnectorBuilder;
use std::error::Error;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    // TLS connector z rustls
    let https = HttpsConnectorBuilder::new()
        .with_webpki_roots()
        .https_or_http()
        .enable_http2()
        .build();

    // Konfiguracja proxy
    let proxy_url: Uri = "http://myuser-country-DE:mypass@gate.proxyhat.com:8080"
        .parse()?;
    let proxy = Proxy::new(Intercept::All, proxy_url);

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

    let client = Client::builder()
        .build(proxy_connector);

    let uri: Uri = "https://httpbin.org/headers".parse()?;
    let resp = client.get(uri).await?;

    println!("Status: {}", resp.status());
    let body = hyper::body::to_bytes(resp.into_body()).await?;
    println!("Body: {}", String::from_utf8_lossy(&body));
    Ok(())
}
# Cargo.toml — zależności dla hyper
[dependencies]
hyper = { version = "1", features = ["client", "http1", "http2"] }
hyper-proxy = "0.9"
hyper-rustls = "0.27"
tokio = { version = "1", features = ["full"] }
http-body-util = "0.1"

Zalety hyper: pełna kontrola nad CONNECT tunelem, możliwość wstrzykiwania custom resolvera DNS, i dostęp do metryk połączenia. Wady — więcej boilerplate, ręczne zarządzanie body streamem.

Współbieżny scraping z tokio::task::JoinSet

Pojedyncze żądanie przez proxy jest wolne (latency proxy + TLS handshake). Prawdziwa wydajność przychodzi z współbieżnością. JoinSet z tokio pozwala zarządzać pulą zadań z limitem jednoczesnych połączeń.

use reqwest::Proxy;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinSet;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let proxy_url = "http://myuser-country-US:mypass@gate.proxyhat.com:8080";

    let client = Arc::new(
        reqwest::Client::builder()
            .proxy(Proxy::all(proxy_url)?)
            .timeout(Duration::from_secs(30))
            .connect_timeout(Duration::from_secs(10))
            .pool_max_idle_per_host(0) // wyłącz keep-alive pool — rotacja IP
            .build()?
    );

    let targets = vec![
        "https://httpbin.org/ip",
        "https://httpbin.org/headers",
        "https://httpbin.org/user-agent",
        "https://httpbin.org/get",
        "https://httpbin.org/origin",
    ];

    let mut tasks = JoinSet::new();
    let max_concurrent = 3;

    for url in &targets {
        // Czekaj, aż liczba aktywnych zadań spadnie poniżej limitu
        while tasks.len() >= max_concurrent {
            let result = tasks.join_next().await.unwrap()?;
            println!("Zakończone: {}", result?);
        }

        let c = client.clone();
        let u = url.to_string();
        tasks.spawn(async move {
            let resp = c.get(&u).send().await?;
            let status = resp.status().as_u16();
            resp.text().await?;
            Ok::<u16, reqwest::Error>(status)
        });
    }

    // Zbierz pozostałe wyniki
    while let Some(result) = tasks.join_next().await {
        let status = result??;
        println!("Zakończone: {}", status);
    }

    Ok(())
}

Ważny detal: pool_max_idle_per_host(0) wyłącza keep-alive pooling w reqwest. Gdy używasz Rust residential proxies z rotacją per-request, keep-alive może przywracać stare IP — wyłączenie poolingu wymusza nowe połączenie za każdym razem.

Rotating proxy pool — abstrakcja przez trait

Hardcodowanie URL proxy w kodzie nie skaluje. Zamiast tego zdefiniuj trait, który dostarcza następny proxy z puli — z rotacją round-robin, sticky sessions albo weighted random.

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

/// Trait abstrakcji dla puli proxy
pub trait ProxyPool: Send + Sync {
    fn next_proxy(&self) -> Result<Proxy, ProxyPoolError>;
}

#[derive(Debug, thiserror::Error)]
pub enum ProxyPoolError {
    #[error("Pula proxy jest pusta")]
    Empty,
    #[error("Błąd parsowania URL proxy: {0}")]
    InvalidUrl(#[from] reqwest::UrlError),
}

/// Pula z rotacją round-robin + geo-targeting
pub struct RotatingProxyPool {
    base_user: String,
    password: String,
    gateway: String,
    countries: Vec<String>,
    index: AtomicUsize,
}

impl RotatingProxyPool {
    pub fn new(
        base_user: &str,
        password: &str,
        gateway: &str,
        countries: Vec<String>,
    ) -> Self {
        Self {
            base_user: base_user.to_string(),
            password: password.to_string(),
            gateway: gateway.to_string(),
            countries,
            index: AtomicUsize::new(0),
        }
    }
}

impl ProxyPool for RotatingProxyPool {
    fn next_proxy(&self) -> Result<Proxy, ProxyPoolError> {
        if self.countries.is_empty() {
            return Err(ProxyPoolError::Empty);
        }

        let idx = self.index.fetch_add(1, Ordering::Relaxed);
        let country = &self.countries[idx % self.countries.len()];

        // Format: user-country-US:pass@gate.proxyhat.com:8080
        let proxy_url = format!(
            "http://{}-country-{}:{}@{}",
            self.base_user, country, self.password, self.gateway
        );

        Proxy::all(&proxy_url).map_err(ProxyPoolError::InvalidUrl)
    }
}

/// Sticky session — ten sam IP przez całą sesję
pub struct StickyProxyPool {
    proxy: Proxy,
}

impl StickyProxyPool {
    pub fn new(
        user: &str,
        password: &str,
        gateway: &str,
        session_id: &str,
    ) -> Result<Self, ProxyPoolError> {
        let url = format!(
            "http://{}-session-{}:{}@{}",
            user, session_id, password, gateway
        );
        Ok(Self {
            proxy: Proxy::all(&url).map_err(ProxyPoolError::InvalidUrl)?,
        })
    }
}

impl ProxyPool for StickyProxyPool {
    fn next_proxy(&self) -> Result<Proxy, ProxyPoolError> {
        // Zwraca ten sam proxy za każdym razem
        Proxy::all(self.proxy.raw_proxy_url()
            .ok_or(ProxyPoolError::Empty)?)
            .map_err(ProxyPoolError::InvalidUrl)
    }
}

// Użycie:
// let pool = RotatingProxyPool::new(
//     "myuser", "mypass", "gate.proxyhat.com:8080",
//     vec!["US".into(), "DE".into(), "JP".into()],
// );
// let proxy = pool.next_proxy()?;

Ta abstrakcja pozwala zamieniać strategie rotacji bez zmiany kodu scrapera. W testach jednostkowych możesz podpiąć mock implementację; w produkcji — RotatingProxyPool albo StickyProxyPool.

Error handling z thiserror — typowe błędy proxy

Proxy dodaje warstwę błędów: timeout połączenia, 407 Proxy Authentication Required, TLS handshake failure, i rate limiting upstream. Zdefiniuj typ błędu z thiserror, żeby uniknąć Box w produkcji.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ScraperError {
    #[error("Błąd proxy: {0}")]
    Proxy(#[from] reqwest::Error),

    #[error("Błąd puli proxy: {0}")]
    Pool(#[from] ProxyPoolError),

    #[error("Rate limit — retry po {wait_ms}ms")]
    RateLimited { wait_ms: u64 },

    #[error("CAPTCHA wykryta na {url}")]
    Captcha { url: String },

    #[error("Timeout po {elapsed_ms}ms dla {url}")]
    Timeout { url: String, elapsed_ms: u64 },
}

/// Retry z exponential backoff
pub async fn fetch_with_retry(
    client: &reqwest::Client,
    url: &str,
    max_retries: u32,
) -> Result<String, ScraperError> {
    let mut attempt = 0;
    loop {
        attempt += 1;
        match client.get(url).send().await {
            Ok(resp) => {
                let status = resp.status();
                if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
                    let wait = 500 * 2u64.pow(attempt);
                    if attempt > max_retries {
                        return Err(ScraperError::RateLimited { wait_ms: wait });
                    }
                    tokio::time::sleep(
                        std::time::Duration::from_millis(wait)
                    ).await;
                    continue;
                }
                return resp.text().await.map_err(ScraperError::Proxy);
            }
            Err(e) => {
                if e.is_timeout() {
                    if attempt > max_retries {
                        return Err(ScraperError::Timeout {
                            url: url.to_string(),
                            elapsed_ms: 30_000,
                        });
                    }
                    tokio::time::sleep(
                        std::time::Duration::from_millis(1000 * attempt as u64)
                    ).await;
                    continue;
                }
                return Err(ScraperError::Proxy(e));
            }
        }
    }
}

rustls vs native-tls — porównanie dla proxy

Wybór backendu TLS wpływa na kompatybilność, rozmiar binarki i zachowanie przy proxy. Oto porównanie:

Cecha rustls native-tls
Linkowanie Statyczne (pure Rust) Dynamiczne (systemowe lib)
Zależności C Brak OpenSSL (Linux)
Rozmiar binarki ~2 MB mniejszy Większy
Certyfikaty enterprise Tylko WebPKI roots System store
Cross-compilation Prosta Wymaga cross-compile OpenSSL
HTTP/2 Tak (via ALPN) Zależy od platformy
Dojrzałość Produkcyjna od 2023 Produkcyjna od lat

Rekomendacja: Dla scraping infrastruktury w kontenerach — rustls-tls. Dla środowisk korporacyjnych z własnymi CA — native-tls. Przełączanie to jedna linijka w Cargo.toml.

Feature flags — kompilacja warunkowa dla proxy

Nie każdy build potrzebuje proxy (np. testy jednostkowe, CLI bez proxy). Użyj feature flags, żeby warunkowo włączyć obsługę proxy.

# Cargo.toml
[features]
default = ["proxy-support"]
proxy-support = ["reqwest/proxy"]

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

# W kodzie:
// src/client.rs
pub fn build_client(use_proxy: bool, proxy_url: Option<&str>)
    -> Result<reqwest::Client, Box<dyn std::error::Error>>
{
    let mut builder = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(30));

    #[cfg(feature = "proxy-support")]
    if let Some(url) = proxy_url {
        builder = builder.proxy(reqwest::Proxy::all(url)?);
    }

    Ok(builder.build()?)
}

// Build bez proxy:
// cargo build --no-default-features

// Build z proxy:
// cargo build

Dzięki temu binarka bez proxy jest mniejsza i szybciej się kompiluje. W CI możesz testować obie ścieżki: cargo test --no-default-features i cargo test.

Produkcyjny scraper — pełny przykład z retry i circuit breaker

Łącząc wszystko — trait proxy pool, error handling, współbieżność i TLS — budujemy scraper gotowy na produkcję:

use reqwest::{Client, Proxy, StatusCode};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
use tokio::task::JoinSet;

pub struct ProductionScraper {
    pool: Arc<dyn ProxyPool>,
    max_retries: u32,
    max_concurrent: usize,
    consecutive_failures: AtomicU32,
    circuit_threshold: u32,
}

impl ProductionScraper {
    pub fn new(
        pool: Arc<dyn ProxyPool>,
        max_retries: u32,
        max_concurrent: usize,
        circuit_threshold: u32,
    ) -> Self {
        Self {
            pool,
            max_retries,
            max_concurrent,
            consecutive_failures: AtomicU32::new(0),
            circuit_threshold,
        }
    }

    fn build_client(&self) -> Result<Client, ScraperError> {
        let proxy = self.pool.next_proxy()?;
        Client::builder()
            .proxy(proxy)
            .timeout(Duration::from_secs(30))
            .connect_timeout(Duration::from_secs(10))
            .pool_max_idle_per_host(0)
            .build()
            .map_err(ScraperError::Proxy)
    }

    /// Sprawdź circuit breaker — jeśli za dużo błędów, odczekaj
    async fn check_circuit(&self) -> Result<(), ScraperError> {
        let failures = self.consecutive_failures.load(Ordering::Relaxed);
        if failures >= self.circuit_threshold {
            let wait = Duration::from_secs(30 * 2u64.pow(failures / self.circuit_threshold));
            eprintln!(
                "Circuit breaker otwarty — {} błędów, czekam {:?}",
                failures, wait
            );
            tokio::time::sleep(wait).await;
        }
        Ok(())
    }

    pub async fn fetch_urls(&self, urls: &[&str]) -> Vec<Result<String, ScraperError>> {
        let mut results = Vec::with_capacity(urls.len());
        let mut tasks = JoinSet::new();

        for url in urls {
            while tasks.len() >= self.max_concurrent {
                self.check_circuit().await.ok();
                let result = tasks.join_next().await.unwrap();
                match &result {
                    Ok(Ok(_)) => {
                        self.consecutive_failures.store(0, Ordering::Relaxed);
                    }
                    _ => {
                        self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
                    }
                }
                results.push(result.map_err(|e| ScraperError::Proxy(
                    e.into()
                )).and_then(|r| r));
            }

            let client = self.build_client()?;
            let url = url.to_string();
            let max_retries = self.max_retries;

            tasks.spawn(async move {
                let mut attempt = 0u32;
                loop {
                    attempt += 1;
                    match client.get(&url).send().await {
                        Ok(resp) => {
                            match resp.status() {
                                s if s.is_success() => {
                                    break Ok(resp.text().await
                                        .map_err(ScraperError::Proxy))
                                }
                                StatusCode::TOO_MANY_REQUESTS => {
                                    if attempt > max_retries {
                                        break Err(ScraperError::RateLimited {
                                            wait_ms: 500 * 2u64.pow(attempt),
                                        });
                                    }
                                    tokio::time::sleep(
                                        Duration::from_millis(500 * 2u64.pow(attempt))
                                    ).await;
                                    continue;
                                }
                                StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => {
                                    break Err(ScraperError::Captcha {
                                        url: url.clone(),
                                    });
                                }
                                _ => {
                                    if attempt > max_retries {
                                        break Err(ScraperError::Proxy(
                                            reqwest::Error::from(
                                                resp.error_for_status().unwrap_err()
                                            )
                                        ));
                                    }
                                    tokio::time::sleep(
                                        Duration::from_millis(300 * attempt as u64)
                                    ).await;
                                    continue;
                                }
                            }
                        }
                        Err(e) if e.is_timeout() => {
                            if attempt > max_retries {
                                break Err(ScraperError::Timeout {
                                    url: url.clone(),
                                    elapsed_ms: 30_000,
                                });
                            }
                            tokio::time::sleep(
                                Duration::from_millis(1000 * attempt as u64)
                            ).await;
                            continue;
                        }
                        Err(e) => break Err(ScraperError::Proxy(e)),
                    }
                }
            });
        }

        // Zbierz pozostałe wyniki
        while let Some(result) = tasks.join_next().await {
            match &result {
                Ok(Ok(_)) => {
                    self.consecutive_failures.store(0, Ordering::Relaxed);
                }
                _ => {
                    self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
                }
            }
            results.push(
                result
                    .map_err(|e| ScraperError::Proxy(e.into()))
                    .and_then(|r| r)
            );
        }

        results
    }
}

Residential vs datacenter vs mobile proxies — co wybrać w Ruście

Wybór typu proxy zależy od przypadku użycia:

  • Residential proxies — IP z prawdziwych ISP. Najlepsze do SERP scraping, e-commerce i stron z agresywną anti-bot ochroną. Wyższa latencja, ale niższy wskaźnik blokad.
  • Datacenter proxies — szybkie, tanie, ale łatwe do wykrycia. Dobre do API bez anti-bota, bulk data transfer, i wstępnego crawlowania.
  • Mobile proxies — IP z sieci komórkowych. Najwyższa zaufaność, idealne do social media i ticketing. Najdroższe i najwolniejsze.

W Ruście możesz łączyć typy — datacenter do wstępnego filtrowania, residential do precyzyjnego pobierania. Trait ProxyPool ułatwia tę strategię.

Etyka i legalność

Scraping przez proxy nie zwalnia z odpowiedzialności:

  • Szanuj robots.txt — przynajmniej dla sygnałów o rate limitach.
  • Przestrzegaj GDPR/CCPA — nie scrapuj danych osobowych bez podstawy prawnej.
  • Czytaj ToS serwisów — niektóre wyraźnie zabraniają automatycznego dostępu.
  • Używaj rozsądnych rate limitów — nawet przez proxy, zalewanie serwera to abuse.

Kluczowe wnioski

  • reqwest z feature proxy to najszybsza droga do działającego proxy w Ruście.
  • hyper daje pełną kontrolę nad CONNECT tunelem — używaj, gdy reqwest to za mało.
  • JoinSet + limit jednoczesnych zadań = kontrolowana współbieżność bez zalewania proxy.
  • Trait ProxyPool pozwala zamieniać strategie rotacji bez zmiany kodu scrapera.
  • thiserror + typed errors = czysta obsługa błędów proxy w produkcji.
  • rustls dla kontenerów, native-tls dla enterprise — przełączanie jedną flagą.
  • Feature flags pozwalają budować bez proxy — szybsza kompilacja i mniejsza binarka.
  • Circuit breaker chroni przed kaskadowymi błędami przy awarii proxy upstream.

Gotowy na budowę? Sprawdź plany ProxyHat i zacznij od residential proxy z geo-targetingiem — albo przejrzyj dostępne lokalizacje, żeby wybrać kraje dla swojej puli. Więcej wzorców scrapingowych znajdziesz w naszym przewodniku po web scrapingu.

Gotowy, aby zacząć?

Dostęp do ponad 50 mln rezydencjalnych IP w ponad 148 krajach z filtrowaniem AI.

Zobacz cenyProxy rezydencjalne
← Powrót do Bloga