TorClient

HTTP client for the Tor network. As simple as reqwest, but routes everything through Tor.

Overview

TorClient is a high-level HTTP client that connects to the Tor network and routes all requests through onion circuits. It provides:

  • Simple API — Familiar request/response pattern
  • Connection pooling — Reuse circuits for performance
  • Circuit isolation — Separate identities per request or session
  • Resilience — Retry, circuit breaker, backpressure
  • Observability — Prometheus metrics, tracing

Basic Usage

Rust

use hypertor::{TorClient, Result};

#[tokio::main]
async fn main() -> Result<()> {
    // Create client (connects to Tor network)
    let client = TorClient::new().await?;
    
    // GET request
    let resp = client.get("http://example.onion")?
        .send().await?;
    println!("Status: {}", resp.status());
    
    // POST with JSON
    let resp = client.post("http://api.onion/data")?
        .json(&serde_json::json!({"key": "value"}))
        .send().await?;
    
    // Read response
    let body: serde_json::Value = resp.json()?;
    println!("{:?}", body);
    
    Ok(())
}

Python

import asyncio
from hypertor import AsyncClient, TimeoutError, HypertorError

async def main():
    async with AsyncClient(timeout=60) as client:
        # GET request
        resp = await client.get("https://check.torproject.org/api/ip")
        print(f"Status: {resp.status_code}")
        print(f"Tor IP: {resp.json().get('IP')}")
        
        # POST with JSON
        resp = await client.post(
            "https://httpbin.org/post",
            json='{"key": "value"}'
        )
        
        # Read response
        body = resp.json()
        print(body)

asyncio.run(main())

Configuration

Use the builder pattern for advanced configuration:

use hypertor::{TorClient, IsolationLevel};
use std::time::Duration;

let client = TorClient::builder()
    // Timeouts
    .timeout(Duration::from_secs(30))
    
    // Circuit isolation (separate identity per request)
    .isolation(IsolationLevel::PerRequest)
    
    // Connection pooling
    .max_connections(20)
    
    // Follow redirects
    .follow_redirects(true)
    .max_redirects(5)
    
    // Build
    .build()
    .await?;

Configuration Options

Option Default Description
timeout 30s Request timeout
isolation None Circuit isolation level
max_connections 10 Max pooled connections
follow_redirects false Follow HTTP redirects
max_redirects 5 Max redirects to follow
verify_tls true Verify TLS certificates

Circuit Isolation

Circuit isolation determines how Tor circuits are shared between requests. This is crucial for both performance (circuit reuse) and privacy (traffic separation).

Level Behavior Use Case
None All requests share circuits Maximum performance, same identity
PerSession Circuits per session/client Default, good balance
PerRequest Fresh circuit per request Maximum privacy, slower
PerHost Circuits per destination Multi-service access
use hypertor::{TorClient, IsolationLevel, IsolationToken};

// Per-request isolation (different IP each time)
let client = TorClient::builder()
    .isolation(IsolationLevel::PerRequest)
    .build().await?;

// Custom isolation token (group related requests)
let token = IsolationToken::new();
let resp = client.get("http://example.onion")?
    .isolation(token.clone())
    .send().await?;

HTTP Methods

// GET
let resp = client.get("http://example.onion")?.send().await?;

// POST with JSON
let resp = client.post("http://example.onion/api")?
    .json(&data)
    .send().await?;

// POST with form data
let resp = client.post("http://example.onion/form")?
    .form(&[("key", "value")])?
    .send().await?;

// PUT
let resp = client.put("http://example.onion/resource")?
    .body("data")
    .send().await?;

// DELETE
let resp = client.delete("http://example.onion/resource")?
    .send().await?;

// HEAD
let resp = client.head("http://example.onion")?.send().await?;

// PATCH
let resp = client.patch("http://example.onion/resource")?
    .json(&partial_update)
    .send().await?;

Query Parameters

// Add query parameters
let resp = client.get("http://example.onion/search")?
    .query(&[("q", "rust"), ("page", "1")])  // ?q=rust&page=1
    .send().await?;

// Multiple values for same key
let resp = client.get("http://example.onion/filter")?
    .query(&[("tag", "security"), ("tag", "tor"), ("tag", "rust")])
    .send().await?;

// With structs (requires serde)
#[derive(Serialize)]
struct SearchParams {
    q: String,
    page: u32,
    limit: u32,
}

let params = SearchParams { q: "tor".into(), page: 1, limit: 50 };
let resp = client.get("http://example.onion/search")?
    .query(&params)
    .send().await?;

Request Headers

// Set individual headers
let resp = client.get("http://example.onion")?
    .header("X-Custom-Header", "value")
    .header("Accept", "application/json")
    .send().await?;

// Set multiple headers
use hypertor::HeaderMap;

let mut headers = HeaderMap::new();
headers.insert("X-API-Key", "secret-key".parse()?);
headers.insert("X-Request-ID", uuid::Uuid::new_v4().to_string().parse()?);

let resp = client.get("http://example.onion")?
    .headers(headers)
    .send().await?;

Working with Responses

let resp = client.get("http://example.onion")?.send().await?;

// Status
let status = resp.status();  // u16
let is_ok = resp.status().is_success();  // 2xx

// Headers
let content_type = resp.headers().get("content-type");

// Body as text
let text = resp.text()?;

// Body as JSON
let data: MyStruct = resp.json()?;

// Body as bytes
let bytes = resp.bytes()?;

// Streaming large responses
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
    let chunk = chunk?;
    process_chunk(&chunk)?;
}

Authentication

// Basic auth
let resp = client.get("http://example.onion")?
    .basic_auth("username", "password")
    .send().await?;

// Bearer token
let resp = client.get("http://example.onion")?
    .bearer_auth("my-token")
    .send().await?;

// Custom authentication header
let resp = client.get("http://example.onion")?
    .header("X-API-Key", "your-api-key")
    .send().await?;

Resilience & Retries

HyperTor includes built-in resilience features:

use hypertor::{TorClient, RetryConfig, CircuitBreakerConfig};
use std::time::Duration;

let client = TorClient::builder()
    // Retry failed requests
    .retry(RetryConfig {
        max_retries: 3,
        initial_delay: Duration::from_millis(100),
        max_delay: Duration::from_secs(5),
        exponential_backoff: true,
        retry_on: vec![500, 502, 503, 504],
    })
    // Circuit breaker (prevent cascade failures)
    .circuit_breaker(CircuitBreakerConfig {
        failure_threshold: 5,
        success_threshold: 2,
        timeout: Duration::from_secs(30),
    })
    .build().await?;

Backpressure & Rate Limiting

Protect onion services from being overwhelmed:

use hypertor::{TorClient, RateLimitConfig};
use std::time::Duration;

let client = TorClient::builder()
    // Client-side rate limiting
    .rate_limit(RateLimitConfig {
        requests_per_second: 10,
        burst_size: 20,
    })
    // Concurrent request limit
    .max_concurrent_requests(5)
    // Backpressure (wait when overwhelmed)
    .backpressure_strategy(BackpressureStrategy::Wait)
    .build().await?;

Circuit Management

Control Tor circuits directly for advanced use cases:

use hypertor::TorClient;

let client = TorClient::new().await?;

// Get current circuit information
let circuit = client.circuit_info().await?;
println!("Circuit ID: {}", circuit.id);
println!("Path: {:?}", circuit.path);
println!("Guard: {}", circuit.guard);
println!("Exit: {}", circuit.exit);

// Force new circuit (new IP address)
client.new_circuit().await?;

// Get multiple circuits for load balancing
let circuits = client.circuits(5).await?;

Observability

Prometheus Metrics

use hypertor::{TorClient, MetricsConfig};

let client = TorClient::builder()
    .metrics(MetricsConfig {
        enabled: true,
        prefix: "hypertor",
        histogram_buckets: vec![0.01, 0.05, 0.1, 0.5, 1.0, 5.0],
    })
    .build().await?;

// Metrics are automatically exported
// hypertor_requests_total{method="GET", status="200"}
// hypertor_request_duration_seconds{method="GET", quantile="0.99"}
// hypertor_circuit_build_time_seconds
// hypertor_active_connections

OpenTelemetry Tracing

use hypertor::{TorClient, TracingConfig};

let client = TorClient::builder()
    .tracing(TracingConfig {
        enabled: true,
        trace_circuits: true,
        trace_requests: true,
    })
    .build().await?;

// Traces include:
// - Circuit establishment
// - Request/response lifecycle
// - Retry attempts
// - Error details

Error Handling

use hypertor::{TorClient, Error};

match client.get("http://example.onion")?.send().await {
    Ok(resp) => println!("Success: {}", resp.status()),
    Err(Error::Timeout { operation, duration }) => {
        println!("{} timed out after {:?}", operation, duration);
    }
    Err(Error::Connection { host, port, .. }) => {
        println!("Failed to connect to {}:{}", host, port);
    }
    Err(Error::CircuitFailed { reason }) => {
        println!("Circuit failed: {}", reason);
    }
    Err(Error::TorNetwork { code, message }) => {
        println!("Tor network error {}: {}", code, message);
    }
    Err(e) => println!("Error: {}", e),
}

Typed API Helpers

For cleaner API interactions with typed serialization/deserialization:

use hypertor::TorClient;
use serde::{Serialize, Deserialize};

#[derive(Serialize)]
struct CreateUser {
    username: String,
    email: String,
}

#[derive(Deserialize)]
struct User {
    id: u64,
    username: String,
    email: String,
}

// Typed POST returning deserialized response
let user: User = client.post("http://api.onion/users")?
    .json(&CreateUser {
        username: "alice".into(),
        email: "alice@example.com".into(),
    })
    .send().await?
    .json()?;

// Typed GET with automatic deserialization
let users: Vec<User> = client.get("http://api.onion/users")?
    .send().await?
    .json()?;

Proxy Chain Support

Chain through additional proxies before Tor:

use hypertor::{TorClient, ProxyConfig};

let client = TorClient::builder()
    // Chain through SOCKS5 proxy before Tor
    .proxy(ProxyConfig::Socks5 {
        host: "127.0.0.1".into(),
        port: 1080,
        auth: None,
    })
    .build().await?;

Next Steps