Rust HTTP Proxy: Полное руководство по reqwest, hyper и ротации прокси

Код-первый гайд по работе с HTTP-прокси в Rust: reqwest с аутентификацией, hyper proxy-connect, конкурентный скрейпинг с tokio, ротация пула через trait, thiserror и выбор TLS-бэкенда.

Rust HTTP Proxy: Полное руководство по reqwest, hyper и ротации прокси

Если вы строите высоконагруженную инфраструктуру скрейпинга на Rust, рано или поздно упрётесь в вопрос: как правильно маршрутизировать запросы через HTTP-прокси? Стандартные клиенты не всегда поддерживают аутентификацию, CONNECT-туннелирование для HTTPS или ротацию IP. Этот гайд — код-первый разбор всего стека: от reqwest с residential proxies до низкоуровневого hyper, от конкурентного сбора с tokio до абстракции пула через trait.

Почему Rust HTTP proxy — это нетривиально

Rust даёт контроль над памятью и конкурентностью, но экосистема HTTP-клиентов фрагментирована. reqwest — самый популярный высокоуровневый клиент, но его прокси-поддержка зависит от feature flags. hyper даёт полный контроль, но требует ручной реализации CONNECT-туннеля для HTTPS через прокси. А если вам нужна ротация residential-прокси с гео-таргетингом — придётся строить собственную абстракцию.

Ключевые проблемы, которые мы решим:

  • Аутентификация на прокси-сервере (ProxyHat и аналоги)
  • HTTPS через HTTP-прокси (CONNECT-метод)
  • Конкурентный скрейпинг с ротацией IP
  • Выбор между rustls и native-tls
  • Обработка ошибок без паники

reqwest: базовая конфигурация прокси

reqwest proxy — самый быстрый старт. Клиент поддерживает HTTP и SOCKS5 прокси из коробки, но нужно включить нужные feature flags в Cargo.toml.

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["proxy", "rustls-tls"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"

Feature proxy включает поддержку reqwest::Proxy, а rustls-tls — TLS через rustls вместо native-tls. Теперь подключаемся к ProxyHat:

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

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    // HTTP-прокси с аутентификацией и гео-таргетингом
    let proxy = Proxy::all("http://user-country-US:password@gate.proxyhat.com:8080")?;

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

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

    let body = resp.text().await?;
    println!("IP через прокси: {}", body);
    Ok(())
}

Формат URL прокси включает учётные данные прямо в строке: http://USERNAME:PASSWORD@gate.proxyhat.com:8080. Для гео-таргетинга ProxyHat кодирует страну в username: user-country-DE, а для sticky-сессий — user-session-abc123.

SOCKS5 и кастомные заголовки

Для SOCKS5 нужен флаг socks и порт 1080:

# Cargo.toml — добавьте "socks" в features reqwest

use reqwest::Proxy;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let proxy = Proxy::all("socks5://user-country-DE-city-berlin:password@gate.proxyhat.com:1080")?;

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

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

hyper: CONNECT-туннель для HTTPS через HTTP-прокси

Когда reqwest не хватает контроля, hyper позволяет построить CONNECT-туннель вручную. Это критично для HTTPS: клиент отправляет CONNECT host:443 HTTP/1.1 на прокси, получает 200, и дальше общается по TLS уже через туннель.

# Cargo.toml
[dependencies]
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] }
http-body-util = "0.1"
tokio = { version = "1", features = ["full"] }
tokio-rustls = "0.26"
rustls = "0.23"
webpki-roots = "0.26"
base64 = "0.22"
use hyper::Request;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use http_body_util::Empty;
use rustls::ClientConfig;
use std::sync::Arc;
use tokio_rustls::TlsConnector;
use tokio::net::TcpStream;
use base64::Engine;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let proxy_host = "gate.proxyhat.com:8080";
    let target = "httpbin.org:443";
    let creds = base64::engine::general_purpose::STANDARD
        .encode("user-country-US:password");

    // 1. TCP к прокси
    let stream = TcpStream::connect(proxy_host).await?;

    // 2. Отправляем CONNECT
    let connect_req = format!(
        "CONNECT {} HTTP/1.1\r\nHost: {}\r\nProxy-Authorization: Basic {}\r\n\r\n",
        target, target, creds
    );
    use tokio::io::AsyncWriteExt;
    use tokio::io::AsyncReadExt;

    let (mut rio, mut wio) = stream.into_split();
    wio.write_all(connect_req.as_bytes()).await?;

    let mut buf = vec![0u8; 4096];
    let n = rio.read(&mut buf).await?;
    let response = String::from_utf8_lossy(&buf[..n]);
    if !response.contains("200") {
        return Err(format!("CONNECT failed: {}", response).into());
    }

    // 3. TLS через туннель (rustls)
    let config = ClientConfig::builder()
        .with_root_certificates(Arc::new(
            rustls::RootCertStore {
                roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
            },
        ))
        .with_no_client_auth();

    let connector = TlsConnector::from(Arc::new(config));
    // Воссоединяем halves для TLS
    let stream = reunite_streams(rio, wio); // упрощённо
    let tls_stream = connector
        .connect("httpbin.org".try_into()?, stream)
        .await?;

    // 4. HTTP-запрос через туннель
    let client: Client<_, Empty::<bytes::Bytes>> = Client::builder(TokioExecutor::new()).build_http();
    let req = Request::builder()
        .uri("https://httpbin.org/ip")
        .body(Empty::new())?;

    let resp = client.request(req).await?;
    println!("Status: {}", resp.status());
    Ok(())
}

// Упрощённая функция воссоединения (в продакшене используйте
// tokio::io:: reunite или сразу работайте с неразделённым stream)
fn reunite_streams<R, W>(_r: R, _w: W) -> TcpStream {
    unimplemented!("Используйте неразделённый TcpStream + TokioIo")
}

Это низкоуровневый подход. В продакшене используйте hyper-util с TokioIo-обёрткой вместо split/reunite. Ключевой вывод: CONNECT-туннель — это просто HTTP-запрос на прокси, после которого вы делаете TLS handshake поверх того же TCP-соединения.

Конкурентный скрейпинг с tokio::JoinSet

Rust residential proxies раскрывают потенциал при конкурентном использовании. tokio::task::JoinSet позволяет запускать сотни задач с ограничением параллелизма:

use reqwest::Proxy;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;

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

    // Ротация стран для каждого запроса
    let countries = ["US", "DE", "FR", "JP", "BR"];
    let counter = Arc::new(AtomicUsize::new(0));

    // Прокси-клиент (пересоздаём для ротации)
    let make_client = |country: &str| -> Result<reqwest::Client, reqwest::Error> {
        let proxy_url = format!(
            "http://user-country-{}:password@gate.proxyhat.com:8080",
            country
        );
        let proxy = Proxy::all(&proxy_url)?;
        reqwest::Client::builder()
            .proxy(proxy)
            .build()
    };

    let semaphore = Arc::new(Semaphore::new(10)); // макс. 10 одновременных
    let mut set = JoinSet::new();

    for (i, url) in urls.into_iter().enumerate() {
        let country = countries[i % countries.len()];
        let client = make_client(country)?;
        let url = url.to_string();
        let sem = semaphore.clone();
        let cnt = counter.clone();

        set.spawn(async move {
            let _permit = sem.acquire().await.unwrap();
            let resp = client.get(&url).send().await?;
            let body = resp.text().await?;
            cnt.fetch_add(1, Ordering::Relaxed);
            Ok::<_, reqwest::Error>((url, body))
        });
    }

    while let Some(result) = set.join_next().await {
        match result {
            Ok(Ok((url, body))) => println!("✓ {} → {}...", url, &body[..body.len().min(80)]),
            Ok(Err(e)) => eprintln!("✗ Ошибка: {}", e),
            Err(e) => eprintln!("✗ Panic: {}", e),
        }
    }

    println!("Завершено запросов: {}", counter.load(Ordering::Relaxed));
    Ok(())
}

Semaphore ограничивает параллелизм, JoinSet собирает результаты. Это лучше, чем join_all, потому что JoinSet освобождает память по мере завершения задач.

Абстракция ротации прокси через trait

Жёсткое кодирование URL прокси — плохая идея для продакшена. Определим trait для пула прокси и реализуем ротацию ProxyHat:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ProxyError {
    #[error("Прокси-пул пуст: нет доступных прокси")]
    PoolExhausted,
    #[error("Все прокси исчерпаны после {attempts} попыток")]
    AllFailed { attempts: usize },
    #[error("Ошибка reqwest: {0}")]
    Reqwest(#[from] reqwest::Error),
}

/// Trait для абстракции источника прокси
#[async_trait::async_trait]
pub trait ProxyPool: Send + Sync {
    type Client;

    /// Получить клиент со следующим прокси из пула
    async fn next_client(&self) -> Result<Self::Client, ProxyError>;

    /// Пометить текущий прокси как заблокированный
    async fn mark_blocked(&self, proxy_id: &str);

    /// Количество доступных прокси
    fn available(&self) -> usize;
}

/// Ротация ProxyHat с гео-таргетингом
pub struct ProxyHatRotator {
    countries: Vec<String>,
    index: AtomicUsize,
    password: String,
}

impl ProxyHatRotator {
    pub fn new(countries: Vec<&str>, password: impl Into<String>) -> Self {
        Self {
            countries: countries.iter().map(|s| s.to_string()).collect(),
            index: AtomicUsize::new(0),
            password: password.into(),
        }
    }
}

#[async_trait::async_trait]
impl ProxyPool for ProxyHatRotator {
    type Client = reqwest::Client;

    async fn next_client(&self) -> Result<Self::Client, ProxyError> {
        if self.countries.is_empty() {
            return Err(ProxyError::PoolExhausted);
        }

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

        let proxy_url = format!(
            "http://user-country-{}:{}@gate.proxyhat.com:8080",
            country, self.password
        );
        let proxy = Proxy::all(&proxy_url)?;

        reqwest::Client::builder()
            .proxy(proxy)
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .map_err(ProxyError::from)
    }

    async fn mark_blocked(&self, _proxy_id: &str) {
        // Для residential-прокси блокировка отдельного IP не нужна —
        // ProxyHat автоматически ротирует. Но можно логировать.
        eprintln!("Прокси заблокирован: {}", _proxy_id);
    }

    fn available(&self) -> usize {
        self.countries.len()
    }
}

// Использование
#[tokio::main]
async fn main() -> Result<(), ProxyError> {
    let pool = ProxyHatRotator::new(
        vec!["US", "DE", "FR", "JP", "BR"],
        "your-password",
    );

    for _ in 0..5 {
        let client = pool.next_client().await?;
        let resp = client.get("https://httpbin.org/ip").send().await?;
        println!("IP: {}", resp.text().await?.trim());
    }

    Ok(())
}

Обработка ошибок с thiserror

Скрейпинг — это мир ошибок: таймауты, 403, CAPTCHA, заблокированные IP. thiserror помогает структурировать ошибки без бойлерплейта:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ScrapeError {
    #[error("HTTP {status} от {url}: заблокировано?")]
    Blocked { status: u16, url: String },

    #[error("Таймаут после {secs}s: {url}")]
    Timeout { secs: u64, url: String },

    #[error("Прокси-ошибка: {0}")]
    Proxy(#[from] ProxyError),

    #[error("Ошибка сети: {0}")]
    Network(#[from] reqwest::Error),
}

/// Обёртка с автоматическим retry и классификацией ошибок
pub async fn fetch_with_retry(
    client: &reqwest::Client,
    url: &str,
    max_retries: usize,
) -> Result<String, ScrapeError> {
    let mut attempts = 0;
    loop {
        attempts += 1;
        match client.get(url).send().await {
            Ok(resp) => {
                let status = resp.status().as_u16();
                if status == 200 {
                    return resp.text().await.map_err(ScrapeError::Network);
                }
                if status == 403 || status == 429 {
                    if attempts >= max_retries {
                        return Err(ScrapeError::Blocked {
                            status,
                            url: url.into(),
                        });
                    }
                    let wait = std::time::Duration::from_millis(500 * attempts as u64);
                    tokio::time::sleep(wait).await;
                    continue;
                }
                return Err(ScrapeError::Blocked { status, url: url.into() });
            }
            Err(e) => {
                if e.is_timeout() {
                    if attempts >= max_retries {
                        return Err(ScrapeError::Timeout {
                            secs: 30,
                            url: url.into(),
                        });
                    }
                    tokio::time::sleep(
                        std::time::Duration::from_secs(attempts as u64),
                    ).await;
                    continue;
                }
                return Err(ScrapeError::Network(e));
            }
        }
    }
}

Exponential backoff с jitter — стандарт для продакшена. Добавьте случайную задержку к wait, чтобы избежать thundering herd.

rustls vs native-tls: что выбрать

Выбор TLS-бэкенда влияет на бинарник, лицензию и поведение:

Критерийrustlsnative-tls
Размер бинарника+2–3 МБ (статическая линковка)+0 МБ (использует системный TLS)
Кросс-компиляцияПростая (нет C-зависимостей)Сложная (OpenSSL / SChannel)
ЛицензияMIT / Apache 2.0Зависит от платформы
TLS 1.3Да, по умолчаниюЗависит от платформы
СовместимостьМожет не работать с редкими CAЛучше со старыми серверами
ПроизводительностьБыстрее на чистом RustСопоставимо с C-оптимизациями

Для скрейпинга рекомендую rustls: кросс-компиляция в Docker проще, нет зависимости от libssl-dev, а TLS 1.3 работает из коробки.

Feature flags: условная компиляция прокси-поддержки

Для библиотек и CLI-инструментов полезно сделать прокси опциональным через feature flags:

# Cargo.toml
[features]
default = ["http-proxy"]
http-proxy = ["reqwest/proxy", "reqwest/rustls-tls"]
socks-proxy = ["reqwest/socks", "reqwest/rustls-tls"]
no-proxy = []
// src/client.rs
#[cfg(feature = "http-proxy")]
pub fn build_client(proxy_url: &str) -> Result<reqwest::Client, reqwest::Error> {
    let proxy = reqwest::Proxy::all(proxy_url)?;
    reqwest::Client::builder()
        .proxy(proxy)
        .build()
}

#[cfg(all(not(feature = "http-proxy"), not(feature = "socks-proxy")))]
pub fn build_client(_proxy_url: &str) -> Result<reqwest::Client, reqwest::Error> {
    reqwest::Client::builder().build()
}

#[cfg(feature = "socks-proxy")]
pub fn build_socks_client(proxy_url: &str) -> Result<reqwest::Client, reqwest::Error> {
    let proxy = reqwest::Proxy::all(proxy_url)?;
    reqwest::Client::builder()
        .proxy(proxy)
        .build()
}

Так вы можете собрать lean-бинарник без прокси для тестов и full-версию для продакшена: cargo build --features "http-proxy,socks-proxy".

Продакшен-советы

  • Connection pooling: reqwest::Client уже переиспользует соединения. Не пересоздавайте клиент на каждый запрос — только если нужна ротация прокси.
  • Sticky sessions: ProxyHat поддерживает сессии через username: user-session-myid123:password@gate.proxyhat.com:8080. Используйте это для многошаговых сценариев (авторизация, корзина).
  • Rate limiting: Даже с residential-прокси не бомбардируйте целевой сервер. governor-crate реализует token bucket.
  • Circuit breaker: Если 5+ запросов подряд падают с 403 — приостановите скрейпинг на минуту. Не усугубляйте.
  • Логирование: Используйте tracing с span per request. Это бесценно при отладке ротации.

Ключевые выводы

Key Takeaways:

  • reqwest с feature proxy — 90% случаев; добавьте rustls-tls для кросс-платформенности.
  • Для HTTPS через HTTP-прокси нужен CONNECT-туннель — hyper даёт полный контроль.
  • Ротация прокси через trait (ProxyPool) делает код тестируемым и расширяемым.
  • tokio::JoinSet + Semaphore — правильный паттерн для конкурентного скрейпинга.
  • thiserror структурирует ошибки; retry с backoff — обязателен в продакшене.
  • rustls проще для Docker-сборок; native-tls лучше для совместимости со старыми серверами.
  • Feature flags позволяют собрать бинарник с/без прокси-поддержки.

Готовы строить инфраструктуру скрейпинга на Rust с Rust residential proxies? Ознакомьтесь с тарифами ProxyHat и доступными локациями, а также с нашим use-case по веб-скрейпингу.

Готовы начать?

Доступ к более чем 50 млн резидентных IP в 148+ странах с AI-фильтрацией.

Смотреть ценыРезидентные прокси
← Вернуться в Блог