Любой парсер, который работает дольше пяти минут на реальных сайтах, рано или поздно упирается в блокировку по IP.
Rust с библиотекой reqwest
даёт отличную производительность, но из коробки не решает проблему ротации прокси — это нужно реализовать самому.
В статье разберём, как подключить SOCKS5 и HTTP(S)-прокси, собрать пул клиентов и организовать ротацию IP так,
чтобы парсер не падал после первых же 429 и 403 ответов.
Зачем парсеру прокси и чем опасна его отсутствие
Сайты вроде маркетплейсов, агрегаторов цен и соцсетей отслеживают аномальную активность с одного IP: десятки запросов в секунду, однотипные User-Agent, отсутствие cookies между запросами. Как только счётчик подозрительных действий превышает порог, IP попадает в бан-лист — временный или постоянный. Для парсера это означает полную остановку сбора данных, пока IP не сменится вручную.
Прокси решают эту проблему, распределяя запросы между десятками или сотнями IP-адресов. При этом важно не просто "подставить прокси", а грамотно организовать пул соединений и ротацию, иначе вы либо упрётесь в лимиты самого reqwest (один клиент = один pool соединений), либо начнёте слать запросы с одного и того же прокси слишком часто. Дальше разберём обе проблемы по очереди: сначала базовое подключение, потом архитектуру пула.
Для продакшн-парсера важна не только ротация IP, но и качество самих адресов. Если вы парсите Wildberries, Ozon или Авито, дата-центровые IP банятся быстрее, чем IP реальных пользователей — об этом подробнее в разделе про выбор типа прокси.
Базовая настройка HTTP/HTTPS прокси в reqwest
reqwest поддерживает прокси через структуру Proxy,
которую нужно передать в ClientBuilder.
Добавьте зависимости в Cargo.toml:
[dependencies]
reqwest = { version = "0.12", features = ["socks", "json"] }
tokio = { version = "1", features = ["full"] }
Минимальный пример с HTTP-прокси, требующим авторизацию по логину и паролю:
use reqwest::{Client, Proxy};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let proxy = Proxy::all("http://gate.proxycove.com:8000")?
.basic_auth("user_login", "user_password");
let client = Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = client
.get("https://httpbin.org/ip")
.send()
.await?
.text()
.await?;
println!("Ответ через прокси: {resp}");
Ok(())
}
Метод Proxy::all
направляет через прокси и HTTP, и HTTPS трафик. Если нужно развести протоколы, используйте
Proxy::http() и
Proxy::https()
отдельно — это полезно, если прокси-провайдер слушает разные порты под разные протоколы.
Подключение SOCKS5 прокси
SOCKS5 работает на более низком уровне, чем HTTP-прокси, и поддерживает TCP-соединения без разбора
содержимого запроса — это быстрее и стабильнее для парсинга, особенно если вы ходите не только по HTTP(S),
но и дёргаете WebSocket-эндпоинты. Для SOCKS5 обязательно включите feature socks
в Cargo.toml (уже указано выше). Пример подключения:
use reqwest::{Client, Proxy};
async fn build_socks5_client(
host: &str,
login: &str,
password: &str,
) -> reqwest::Result<Client> {
let proxy_url = format!("socks5h://{host}");
let proxy = Proxy::all(&proxy_url)?.basic_auth(login, password);
Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(20))
.build()
}
Обратите внимание на схему socks5h
вместо socks5.
Буква "h" в конце означает, что DNS-резолвинг домена происходит на стороне прокси-сервера, а не локально.
Это критично для анонимности: без неё ваш реальный DNS-резолвер может "слить" запрашиваемые домены мимо прокси,
даже если сам трафик идёт через SOCKS5-туннель.
Если прокси-провайдер не требует авторизации по логину/паролю (IP-whitelisting), просто не вызывайте
basic_auth —
укажите в панели провайдера IP-адрес вашего сервера, с которого идут запросы.
Пул клиентов reqwest для разных прокси
Частая ошибка — создавать новый Client
на каждый запрос. Это дорого: reqwest внутри держит пул TCP-соединений и TLS-сессий, и пересоздание клиента
убивает все преимущества keep-alive. Правильный подход — создать по одному клиенту на каждый прокси заранее
и переиспользовать их из общего пула.
use reqwest::{Client, Proxy};
use std::sync::Arc;
#[derive(Clone)]
struct ProxyEndpoint {
url: String,
login: String,
password: String,
}
struct ClientPool {
clients: Vec<Client>,
}
impl ClientPool {
fn build(endpoints: &[ProxyEndpoint]) -> reqwest::Result<Self> {
let mut clients = Vec::with_capacity(endpoints.len());
for ep in endpoints {
let proxy = Proxy::all(&ep.url)?
.basic_auth(&ep.login, &ep.password);
let client = Client::builder()
.proxy(proxy)
.pool_max_idle_per_host(4)
.timeout(std::time::Duration::from_secs(15))
.build()?;
clients.push(client);
}
Ok(Self { clients })
}
}
Такой пул создаётся один раз при старте парсера. Количество клиентов обычно равно количеству уникальных прокси-адресов в вашем пуле — если провайдер даёт доступ к ротации на уровне шлюза (gateway), достаточно одного-двух клиентов, так как ротация IP происходит автоматически на стороне провайдера при каждом новом TCP-соединении.
Для задач с высокой параллельностью оберните пул в Arc
и расшарьте между tokio-тасками — клиенты reqwest потокобезопасны и спроектированы для конкурентного использования.
Стратегии ротации IP
Есть два принципиально разных подхода к ротации. Первый — ротация на стороне провайдера: вы подключаетесь к одному gateway-адресу, а провайдер сам подставляет новый IP на каждый запрос или раз в N минут. Второй — ротация на вашей стороне: вы держите список статичных прокси и сами выбираете, какой использовать для следующего запроса.
Если вы используете резидентные прокси с автоматической ротацией через gateway, код упрощается до минимума — просто шлёте запросы через один и тот же клиент, провайдер меняет исходящий IP сам. Но если нужен контроль (например, закрепить IP за сессией на 10 минут для имитации поведения реального пользователя), реализуйте ротацию вручную:
use rand::seq::SliceRandom;
use std::sync::atomic::{AtomicUsize, Ordering};
struct RotatingPool {
clients: Vec<Client>,
counter: AtomicUsize,
}
impl RotatingPool {
// Round-robin: по очереди перебираем клиентов
fn next_round_robin(&self) -> &Client {
let idx = self.counter.fetch_add(1, Ordering::SeqCst) % self.clients.len();
&self.clients[idx]
}
// Случайный выбор — полезно против паттерн-анализа
fn next_random(&self) -> &Client {
self.clients.choose(&mut rand::thread_rng()).unwrap()
}
}
Round-robin подходит, когда важна равномерная нагрузка на все IP. Случайный выбор лучше маскирует паттерн запросов, если целевой сайт анализирует интервалы между обращениями с одного адреса. Для задач вроде мониторинга цен на Ozon или сбора объявлений с Авито на практике эффективнее комбинация: случайный выбор прокси плюс случайная задержка 1–3 секунды между запросами одного и того же клиента.
Отдельно стоит сказать про sticky-сессии. Если парсер проходит многошаговый сценарий (логин → переход по страницам → действие), IP менять нельзя посреди сессии — сайт заметит смену IP при активной cookie-сессии и либо разлогинит, либо заблокирует аккаунт. Для таких сценариев закрепляйте один клиент (и соответственно один IP) за конкретной "сессией" парсера на всё время её жизни.
Обработка ошибок и retry-логика
Прокси время от времени отваливаются: таймауты, 407 (Proxy Authentication Required), обрыв TCP-соединения. Парсер должен уметь отличать "прокси сломан — исключить из пула" от "сайт вернул 429 — просто подождать и сменить IP". Вот рабочий паттерн с retry и исключением мёртвых прокси:
use reqwest::StatusCode;
use std::time::Duration;
async fn fetch_with_retry(
pool: &RotatingPool,
url: &str,
max_attempts: u32,
) -> Result<String, String> {
let mut last_error = String::new();
for attempt in 0..max_attempts {
let client = pool.next_random();
match client.get(url).send().await {
Ok(resp) if resp.status() == StatusCode::TOO_MANY_REQUESTS => {
last_error = "429: rate limit, меняем IP".into();
tokio::time::sleep(Duration::from_millis(500 * (attempt as u64 + 1))).await;
continue;
}
Ok(resp) if resp.status().is_success() => {
return resp.text().await.map_err(|e| e.to_string());
}
Ok(resp) => {
last_error = format!("Неожиданный статус: {}", resp.status());
}
Err(e) if e.is_timeout() || e.is_connect() => {
last_error = format!("Проблема с прокси: {e}");
continue;
}
Err(e) => {
last_error = e.to_string();
}
}
}
Err(format!("Все попытки исчерпаны: {last_error}"))
}
Важный нюанс: экспоненциальная задержка (500 * attempt)
снижает нагрузку на целевой сайт при массовых 429-ответах и одновременно уменьшает риск полной блокировки
всего пула прокси разом. Для продакшн-парсеров добавьте счётчик ошибок на каждый прокси в пуле и автоматически
исключайте адрес, если он даёт 5+ ошибок подряд — это убережёт от бесполезных попыток через уже забаненный IP.
Какой тип прокси выбрать для парсинга
Выбор типа прокси напрямую влияет на то, сколько успешных запросов вы получите до блокировки. Дата-центровые IP самые дешёвые и быстрые, но принадлежат известным диапазонам хостинг-провайдеров — антибот-системы маркетплейсов и соцсетей вычисляют их почти мгновенно по базам ASN. Резидентные и мобильные IP выглядят как обычные пользователи, поэтому живут в разы дольше.
| Тип прокси | Скорость | Риск блокировки | Когда применять |
|---|---|---|---|
| Дата-центр | Высокая | Высокий | Открытые API, сайты без антибота |
| Резидентные | Средняя | Низкий | Wildberries, Ozon, Авито, соцсети |
| Мобильные | Средняя | Минимальный | Instagram, TikTok, строгие антиботы |
Для парсинга маркетплейсов на больших объёмах разумный баланс — резидентные прокси: они не такие дорогие в пересчёте на трафик, как мобильные, но проходят проверки на уровне "реальный пользователь", которые режут дата-центровые диапазоны. Если же парсер работает через API соцсетей или имитирует мобильное приложение, мобильные прокси дают наименьший процент капч и временных банов за счёт IP, закреплённых за операторами связи.
Производительность и советы по нагрузке
Rust позволяет держать тысячи конкурентных запросов на одном инстансе, но прокси-провайдер и целевой сайт всё равно ограничивают реальную пропускную способность. Несколько практических правил для production-парсера:
- Ограничивайте параллелизм через
tokio::sync::Semaphore— не более 20-50 одновременных запросов на один прокси-пул, иначе часть соединений начнёт обрываться по таймауту. - Используйте
pool_idle_timeoutв ClientBuilder, чтобы reqwest не держал мёртвые соединения через нестабильные прокси. - Логируйте HTTP-статусы и время ответа по каждому прокси отдельно — так вы быстро увидите, какие адреса начали "гнить" и подлежат замене.
- Ротация User-Agent вместе с ротацией IP существенно снижает вероятность детекта: меняйте их синхронно, а не по отдельным циклам.
- Для долгоживущих парсеров добавьте health-check: раз в несколько минут отправляйте тестовый запрос на https://httpbin.org/ip через каждый прокси и исключайте неотвечающие.
Отдельное внимание уделите TLS-отпечаткам (JA3/JA3S). reqwest по умолчанию использует rustls или nativetls
в зависимости от фичей сборки, и у обоих разные отпечатки TLS-рукопожатия. Некоторые антибот-системы (например,
у крупных маркетплейсов) анализируют именно TLS fingerprint, а не только IP — поэтому даже идеальный прокси
не спасёт, если сам клиент выдаёт себя нестандартным TLS-хендшейком. Если сталкиваетесь с блокировками при
"чистых" прокси, проверьте фичи reqwest и попробуйте фичу native-tls вместо rustls, или наоборот.
Заключение
Прокси в Rust reqwest настраиваются быстро — пара строк с Proxy::all
или SOCKS5-схемой socks5h://.
Основная работа начинается дальше: построение пула клиентов, выбор стратегии ротации (round-robin, случайная
или sticky-сессии) и грамотная обработка ошибок с исключением нерабочих адресов из пула. Без этого даже
самый быстрый парсер на Rust упрётся в блокировки через несколько минут работы.
Если ваш парсер работает с маркетплейсами или соцсетями, где антибот-защита анализирует не только частоту запросов, но и происхождение IP, начните с резидентных прокси — они дают лучший баланс цены и живучести сессий. Для более строгих целей вроде Instagram и TikTok стоит протестировать мобильные IP, а для простых задач без антибота вполне хватит быстрых и недорогих прокси дата-центров.