Если вы строите высоконагруженную инфраструктуру скрейпинга на 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-бэкенда влияет на бинарник, лицензию и поведение:
| Критерий | rustls | native-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 по веб-скрейпингу.






