Rust HTTPプロキシ完全ガイド:reqwest・hyper・非同期スクレイピング実装

RustでHTTPプロキシを活用する実践的なガイド。reqwestのプロキシ設定・認証、hyperの低レベル実装、tokio+JoinSetによる並行スクレイピング、ローテーションプロキシプールのトレイト設計、thiserrorによる堅牢なエラー処理まで詳解。

Rust HTTPプロキシ完全ガイド:reqwest・hyper・非同期スクレイピング実装

Rustで大規模なスクレイピング基盤を構築するなら、Rust HTTPプロキシの正しい扱いは避けて通れません。高並行・低レイテンシが求められる場面では、プロキシの設定ミスやTLSの不整合が即座にボトルネックになります。本記事では、reqwest proxyの基本から、hyperによる低レベルなHTTPS-over-HTTP-Proxy、Rust residential proxiesを活用したローテーション抽象まで、本番で使えるコードを6つ以上の実行可能ブロックで解説します。

reqwestでプロキシを設定する

reqwestはRustエコシステムで最も使われているHTTPクライアントです。プロキシの設定はProxyビルダーで行います。認証付きのレジデンシャルプロキシを使う場合、URLにクレデンシャルを埋め込むのが最もシンプルです。

use reqwest::{Client, Proxy};
use std::error::Error;

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

    let client = Client::builder()
        .proxy(proxy)
        .user_agent("Mozilla/5.0 (compatible; Scraper/1.0)")
        .build()?;

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

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

ポイントは3つです:

  • Proxy::all()はHTTPとHTTPSの両方のリクエストをプロキシ経由にします
  • URLにUSERNAME:PASSWORDを含めると、Proxy-Authorizationヘッダーが自動付与されます
  • ProxyHatのユーザー名に-country-USのようなフラグを追加するとジオターゲティングが有効になります

SOCKS5プロキシを使う場合

SOCKS5が必要な場合は、Cargo.tomlsocksフィーチャーを有効にしてください。

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["socks"] }
tokio = { version = "1", features = ["full"] }
use reqwest::{Client, Proxy};
use std::error::Error;

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

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

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

reqwestでカスタムTLSを設定する

スクレイピングではTLSフィンガープリントの制御が重要です。reqwestはrustlsnative-tlsのいずれかを選べます。

rustlsを使う場合

rustlsは純Rust実装のTLSスタックで、クロスコンパイルが容易です。デフォルトではaws-lc-rsがバックエンドです。

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

native-tlsを使う場合

native-tlsはOSのネイティブTLS(macOSではSecurity.framework、WindowsではSChannel、LinuxではOpenSSL)を使います。既存のPKIやエンタープライズ証明書との互換性が必要な場合はこちらを選びます。

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", default-features = false, features = [
    "native-tls",
    "proxy",
] }
特性rustlsnative-tls
コンパイル要件Rustのみ(Cコンパイラ不要)OpenSSL dev headers(Linux)
クロスコンパイル容易困難(ターゲットのOpenSSL必要)
TLS 1.3デフォルト有効プラットフォーム依存
証明書ピンニングカスタムルート可能OSのトラストストア使用
パフォーマンス高速(純Rust最適化)成熟したC実装

カスタムルート証明書を追加する例:

use reqwest::{Client, Proxy, Certificate};
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 cert_pem = std::fs::read("custom-root-ca.pem")?;
    let cert = Certificate::from_pem(&cert_pem)?;

    let client = Client::builder()
        .proxy(proxy)
        .add_root_certificate(cert)
        .danger_accept_invalid_certs(false)
        .build()?;

    let resp = client.get("https://internal.example.com/api").send().await?;
    println!("Status: {}", resp.status());
    Ok(())
}

hyperでHTTPS-over-HTTP-Proxyを実装する

hyperは低レベルなHTTPライブラリで、プロキシ機能が組み込まれていません。HTTPSサイトにHTTPプロキシ経由でアクセスするには、CONNECTトンネルを自分で確立する必要があります。この制御の細かさは、カスタムプロトコルや特殊な認証フローに不可欠です。

use hyper::{Client, Uri, Request, Body, StatusCode};
use hyper::client::HttpConnector;
use hyper::rt::{Read, Write};
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use std::sync::Arc;
use rustls::{ClientConfig, RootCertStore};
use rustls_pki_types::ServerName;

async fn connect_via_proxy(
    target_host: &str,
    target_port: u16,
    proxy_host: &str,
    proxy_port: u16,
    proxy_auth: &str,
) -> Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>, Box<dyn std::error::Error>> {
    // 1. TCP connect to proxy
    let mut stream = TcpStream::connect((proxy_host, proxy_port)).await?;

    // 2. Send CONNECT request
    let connect_req = format!(
        "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\nProxy-Authorization: Basic {}\r\n\r\n",
        target_host, target_port,
        target_host, target_port,
        base64::encode(proxy_auth),
    );
    use tokio::io::AsyncWriteExt;
    stream.write_all(connect_req.as_bytes()).await?;

    // 3. Read proxy response
    use tokio::io::AsyncReadExt;
    let mut buf = vec![0u8; 4096];
    let n = stream.read(&mut buf).await?;
    let response = String::from_utf8_lossy(&buf[..n]);
    if !response.contains("200") {
        return Err(format!("CONNECT failed: {}", response).into());
    }

    // 4. TLS handshake over the tunneled connection
    let mut root_store = RootCertStore::empty();
    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
    let config = ClientConfig::builder()
        .with_root_certificates(root_store)
        .with_no_client_auth();
    let connector = TlsConnector::from(Arc::new(config));
    let server_name = ServerName::try_from(target_host)?;

    let tls_stream = connector.connect(server_name, stream).await?;
    Ok(tls_stream)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let tls_stream = connect_via_proxy(
        "httpbin.org", 443,
        "gate.proxyhat.com", 8080,
        "user-country-GB:password",
    ).await?;

    // 5. Send HTTP request over the TLS tunnel
    let (mut sender, conn) = hyper::client::conn::handshake(tls_stream).await?;
    tokio::spawn(async move { if let Err(e) = conn.await { eprintln!("Connection error: {}", e); } });

    let req = Request::builder()
        .uri("/ip")
        .header("Host", "httpbin.org")
        .body(Body::empty())?;

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

hyperのCONNECT実装は手動ですが、プロキシ認証ヘッダーのカスタマイズや、プロキシチェーンの構築など、reqwestでは不可能な制御が可能です。

tokio + JoinSetで並行スクレイピング

大規模スクレイピングでは、数百のリクエストを並行実行しつつ、コネクション数とレートリミットを制御する必要があります。tokio::task::JoinSetは構造化された並行性を提供し、タスクのキャンセルも安全に行えます。

use reqwest::{Client, Proxy};
use tokio::task::JoinSet;
use std::sync::Arc;
use std::time::Duration;

#[derive(Debug, thiserror::Error)]
enum ScrapeError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),
    #[error("Rate limited (status {0})")]
    RateLimited(u16),
}

struct ScrapeResult {
    url: String,
    status: u16,
    body_preview: String,
}

async fn fetch_page(
    client: &Client,
    url: &str,
) -> Result<ScrapeResult, ScrapeError> {
    let resp = client.get(url).send().await?;
    let status = resp.status().as_u16();

    if status == 429 {
        return Err(ScrapeError::RateLimited(status));
    }

    let body = resp.text().await?;
    let preview = body.chars().take(200).collect();

    Ok(ScrapeResult {
        url: url.to_string(),
        status,
        body_preview: preview,
    })
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let proxy = Proxy::all("http://user-country-US:password@gate.proxyhat.com:8080")?;
    let client = Arc::new(
        Client::builder()
            .proxy(proxy)
            .timeout(Duration::from_secs(15))
            .pool_max_idle_per_host(0) // disable keep-alive for IP rotation
            .build()?
    );

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

    let mut tasks: JoinSet<Result<ScrapeResult, ScrapeError>> = JoinSet::new();

    // Launch all tasks with a small stagger to avoid thundering herd
    for url in &urls {
        let client = Arc::clone(&client);
        let url = url.to_string();
        tasks.spawn(async move {
            tokio::time::sleep(Duration::from_millis(50)).await;
            fetch_page(&client, &url).await
        });
    }

    while let Some(result) = tasks.join_next().await {
        match result.unwrap() {
            Ok(scrape) => println!("[{}] {} - {}...",
                scrape.status, scrape.url, scrape.body_preview),
            Err(e) => eprintln!("Error: {}", e),
        }
    }

    Ok(())
}

pool_max_idle_per_host(0)を設定することで、各リクエストが新しいコネクションを確立し、プロキシ側でのIPローテーションが確実に反映されます。これはRust residential proxiesを使ったパーセッション・ローテーションで特に重要です。

ローテーション・プロキシプールのトレイト設計

本番スクレイピングでは、単一プロキシではなくプールからのIPローテーションが必要です。トレイトで抽象化すると、テスト時はモック、本番時はProxyHatという切り替えが可能です。

use async_trait::async_trait;
use reqwest::{Client, Proxy};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::VecDeque;

/// Abstraction over a pool of rotating proxies
#[async_trait]
pub trait ProxyPool: Send + Sync {
    /// Get the next proxy URL from the pool
    async fn next_proxy_url(&self) -> Option<String>;
    /// Report a proxy failure so it can be deprioritized
    async fn report_failure(&self, proxy_url: &str);
    /// Report success
    async fn report_success(&self, proxy_url: &str);
}

/// Round-robin pool backed by ProxyHat with geo-rotation
pub struct ProxyHatPool {
    username_base: String,
    password: String,
    countries: VecDeque<String>,
    session_counter: Arc<RwLock<u64>>,
}

impl ProxyHatPool {
    pub fn new(username: &str, password: &str, countries: Vec<String>) -> Self {
        Self {
            username_base: username.to_string(),
            password: password.to_string(),
            countries: countries.into(),
            session_counter: Arc::new(RwLock::new(0)),
        }
    }

    fn build_proxy_url(&self, country: &str, session_id: &str) -> String {
        format!(
            "http://{}-country-{}-session-{}:{}@gate.proxyhat.com:8080",
            self.username_base, country, session_id, self.password
        )
    }
}

#[async_trait]
impl ProxyPool for ProxyHatPool {
    async fn next_proxy_url(&self) -> Option<String> {
        let country = self.countries.front()?.clone();
        // Rotate to next country for next call
        let mut countries = self.countries.clone();
        countries.rotate_left(1);
        // We can't mutate &self, so use interior mutability pattern
        // In production, wrap countries in RwLock as well

        let mut counter = self.session_counter.write().await;
        *counter += 1;
        let session = format!("rust-{}", counter);

        Some(self.build_proxy_url(&country, &session))
    }

    async fn report_failure(&self, _proxy_url: &str) {
        // In production: add to cooldown map, circuit-breaker logic
        eprintln!("Proxy failure reported: {}", _proxy_url);
    }

    async fn report_success(&self, _proxy_url: &str) {
        // In production: reset failure counter
    }
}

/// Build a reqwest client using the pool's next proxy
pub async fn client_from_pool(pool: &dyn ProxyPool) -> Result<Client, reqwest::Error> {
    match pool.next_proxy_url().await {
        Some(url) => {
            let proxy = Proxy::all(&url)?;
            Client::builder()
                .proxy(proxy)
                .build()
        }
        None => Client::builder().build(), // no proxy fallback
    }
}

// Usage example
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pool = ProxyHatPool::new(
        "myuser", "mypass",
        vec!["US".into(), "DE".into(), "JP".into(), "GB".into()],
    );

    for _ in 0..4 {
        if let Some(proxy_url) = pool.next_proxy_url().await {
            let proxy = Proxy::all(&proxy_url)?;
            let client = Client::builder().proxy(proxy).build()?;
            let resp = client.get("https://httpbin.org/ip").send().await?;
            println!("IP: {}", resp.text().await?);
        }
    }
    Ok(())
}

このトレイト設計の利点:

  • テスト時にMockProxyPoolを注入可能
  • 国ローテーションとスティッキーセッションを1つのURLで表現(ProxyHatの仕様に準拠)
  • 失敗報告によりサーキットブレーカーやクールダウンを追加できる拡張ポイント

thiserrorで堅牢なエラー処理

プロキシ経由のスクレイピングでは、ネットワークエラー、プロキシ認証エラー、レートリミット、TLSエラーなど多様なエラーが発生します。thiserrorで構造化されたエラー型を定義し、リトライロジックと組み合わせましょう。

use thiserror::Error;
use std::time::Duration;

#[derive(Debug, Error)]
pub enum ProxyScrapeError {
    #[error("Connection to proxy failed: {0}")]
    ProxyConnection(String),

    #[error("Proxy authentication rejected (status {status})")]
    ProxyAuth { status: u16 },

    #[error("Rate limited by target (status 429, retry after {retry_after_ms:?}ms)")]
    RateLimited { retry_after_ms: Option<u64>>,

    #[error("TLS handshake failed: {0}")]
    Tls(String),

    #[error("HTTP client error: {0}")]
    Http(#[from] reqwest::Error),

    #[error("No proxies available in pool")]
    PoolExhausted,

    #[error("Request timeout after {0}ms")]
    Timeout(u64),
}

/// Retry with exponential backoff
pub async fn retry_with_backoff<F, Fut, T>
    mut f: F,
    max_retries: u32,
) -> Result<T, ProxyScrapeError>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = Result<T, ProxyScrapeError>>,
{
    let mut attempt = 0;
    loop {
        match f().await {
            Ok(val) => return Ok(val),
            Err(e) if attempt >= max_retries => return Err(e),
            Err(ProxyScrapeError::RateLimited { retry_after_ms }) => {
                let delay = retry_after_ms
                    .map(Duration::from_millis)
                    .unwrap_or(Duration::from_secs(2 * 2u64.pow(attempt)));
                eprintln!("Rate limited, waiting {:?}...", delay);
                tokio::time::sleep(delay).await;
            }
            Err(e) => {
                let delay = Duration::from_millis(100 * 2u64.pow(attempt));
                eprintln!("Attempt {} failed: {}, retrying in {:?}", attempt + 1, e, delay);
                tokio::time::sleep(delay).await;
            }
        }
        attempt += 1;
    }
}

// Example usage
#[tokio::main]
async fn main() -> Result<(), ProxyScrapeError> {
    let proxy_url = "http://user-country-US:password@gate.proxyhat.com:8080";
    let proxy = reqwest::Proxy::all(proxy_url)
        .map_err(|e| ProxyScrapeError::ProxyConnection(e.to_string()))?;

    let client = reqwest::Client::builder()
        .proxy(proxy)
        .timeout(Duration::from_secs(10))
        .build()?;

    let result = retry_with_backoff(
        || async {
            let resp = client
                .get("https://httpbin.org/ip")
                .send()
                .await?;

            let status = resp.status().as_u16();
            if status == 429 {
                let retry_after = resp.headers()
                    .get("retry-after")
                    .and_then(|v| v.to_str().ok())
                    .and_then(|v| v.parse::<u64>().ok())
                    .map(|s| s * 1000);
                return Err(ProxyScrapeError::RateLimited { retry_after_ms: retry_after });
            }
            if status == 407 {
                return Err(ProxyScrapeError::ProxyAuth { status });
            }

            let body = resp.text().await?;
            Ok(body)
        },
        3,
    ).await?;

    println!("Result: {}", result);
    Ok(())
}

コンパイル時フィーチャーフラグでプロキシ対応を制御する

ライブラリやCLIツールを配布する場合、プロキシ機能をオプションにしたい場面があります。フィーチャーフラグでコンパイル時に制御することで、不要な依存を削減できます。

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

[features]
default = []
proxy-support = ["reqwest/proxy"]
socks5 = ["proxy-support", "reqwest/socks"]
rustls-tls = ["reqwest/rustls-tls"]
native-tls = ["reqwest/native-tls"]
full = ["proxy-support", "socks5", "rustls-tls", "reqwest/http2"]
// src/proxy.rs
#[cfg(feature = "proxy-support")]
use reqwest::Proxy;

/// Build a client with optional proxy support
pub fn build_client(proxy_url: Option<&str>) -> Result<reqwest::Client, reqwest::Error> {
    let mut builder = reqwest::Client::builder();

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

    #[cfg(not(feature = "proxy-support"))]
    let _ = proxy_url; // suppress unused warning

    builder.build()
}

// ---
// src/main.rs
fn main() {
    #[cfg(feature = "proxy-support")]
    let proxy_url = Some("http://user-country-US:password@gate.proxyhat.com:8080");

    #[cfg(not(feature = "proxy-support"))]
    let proxy_url: Option<&str> = None;

    let client = build_client(proxy_url).expect("Failed to build client");

    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        let resp = client.get("https://httpbin.org/ip").send().await.unwrap();
        println!("{}", resp.text().await.unwrap());
    });
}

このパターンにより、cargo buildではプロキシなし、cargo build --features proxy-supportでプロキシあり、という切り替えがコンパイル時に行われます。バイナリサイズとコンパイル時間の両方を最適化できます。

本番運用のベストプラクティス

1. コネクションプールとIPローテーション

レジデンシャルプロキシでパーリクエスト・ローテーションを使う場合、pool_max_idle_per_host(0)を設定してキープアライブを無効化してください。そうしないと、同じIPで複数リクエストが送信されます。

2. スティッキーセッション

ログイン後のスクレイピングなどで同じIPを維持したい場合は、ProxyHatの-session-{id}フラグを使います。セッションIDが同じ限り、同じIPが割り当てられます。

// Sticky session: same IP for all requests in this session
let proxy_url = "http://user-country-US-session-order-12345:password@gate.proxyhat.com:8080";

3. レートリミットとサーキットブレーカー

429や503が連続する場合は、指数バックオフでリトライしつつ、一定回数を超えたらそのプロキシを一時的にプールから除外します。前述のretry_with_backoffProxyPool::report_failureを組み合わせてください。

4. ロギングとモニタリング

各リクエストのプロキシIP、ステータスコード、レイテンシをログに記録しましょう。tracingクレートを使えば構造化ログが簡単に取得できます。

Key Takeaways

  • reqwestならProxy::all()1行でプロキシ対応。認証はURLにクレデンシャルを含めるのが最もシンプル。
  • hyperでCONNECTトンネルを自前実装すると、プロキシ認証やチェーンの完全な制御が可能。
  • tokio::task::JoinSetで構造化された並行スクレイピング。サーキットブレーカーやレートリミットと組み合わせる。
  • ProxyPoolトレイトでローテーションを抽象化。テスト時はモック、本番時はProxyHatのジオターゲティング付きプール。
  • thiserrorでエラーを構造化。リトライ可能かどうかを型で表現し、指数バックオフと統合。
  • フィーチャーフラグでプロキシ機能をコンパイル時オプションに。配布バイナリのサイズ最適化に必須。
  • rustls vs native-tls:クロスコンパイルならrustls、エンタープライズPKIならnative-tlsを選ぶ。

Rustの型システムと非同期ランタイムを活かせば、プロキシ対応のスクレイピング基盤も安全かつ高速に構築できます。ProxyHatのレジデンシャルプロキシ195+のロケーションを組み合わせれば、ジオターゲティング付きの高信頼スクレイピングが数行で実現します。より詳しいユースケースはWebスクレイピングのユースケースも参照してください。

始める準備はできましたか?

AIフィルタリングで148か国以上、5,000万以上のレジデンシャルIPにアクセス。

料金を見るレジデンシャルプロキシ
← ブログに戻る