aioduct 0.2.5

Async-native HTTP client built directly on hyper 1.x — no hyper-util, no legacy
Documentation

aioduct

Crates.io docs.rs CI License: MIT OR Apache-2.0 MSRV: 1.95

Async-native Rust HTTP client built directly on hyper 1.x — no hyper-util, no legacy APIs.

Documentation | API Reference | Crates.io

Why aioduct?

  • reqwest depends on hyper-util's legacy::Client, wrapping hyper 0.x-style patterns over hyper 1.x with years of backwards-compatibility baggage.
  • hyper-util labels its own client as "legacy" — the hyper team acknowledges it's not the long-term answer.
  • hyper 1.x provides clean connection-level primitives, but no production client uses them directly.

aioduct uses hyper 1.x the way it was intended — as a protocol engine you drive yourself, with your own connection pool, TLS, and runtime integration.

Features

  • No hyper-util — custom IO adapters and executor directly against hyper::rt traits
  • Multi-runtime — tokio, smol, and compio (io_uring) via feature flags; compatible WASM browser/worker and WASI Preview 2 support
  • rustls TLS — async handshake with ALPN-based HTTP/1.1 and HTTP/2 negotiation
  • Connection pooling — keyed by scheme, authority, protocol hint, proxy route, forced transport address, and effective HTTP/3 endpoint, with idle timeout and per-host limits plus pool_stats() diagnostics (hit/miss/eviction counters and per-host idle/active inventory)
  • Redirect following — RFC-compliant handling of 301/302/303/307/308 with sensitive header stripping and content header removal
  • Cookie jar — automatic cookie storage, domain/path/subdomain matching, Max-Age and Expires expiration, Secure flag enforcement, SameSite (Strict/Lax/None), cookie prefixes (__Host-, __Secure-)
  • Timeouts — client-level and per-request total, connect, read, and write timeouts, plus per-request timeout bypass
  • Retry — configurable exponential backoff with retry budgets, Retry-After header support, 429 Too Many Requests retry, and custom retry classification
  • Decompression — automatic gzip, brotli, zstd, deflate response decompression
  • Proxy — HTTP CONNECT tunneling, HTTPS proxy, SOCKS4/SOCKS4a, SOCKS5 (local DNS), SOCKS5h (remote DNS), proxy chaining up to 2 hops, URI-embedded credentials, credential resolver, system proxy detection (HTTP_PROXY/HTTPS_PROXY/NO_PROXY)
  • Middleware — pluggable request/response interceptors via trait or closure
  • Rate limiting — token-bucket rate limiter for outgoing requests
  • Caching — in-memory HTTP cache with immutable responses, stale-while-revalidate, stale-if-error (fallback on 5xx/connection failure); pluggable CacheStore trait for custom backends
  • HSTS — automatic HTTP-to-HTTPS upgrade for Strict-Transport-Security domains
  • SSE — Server-Sent Events stream parsing for LLM APIs
  • Multipartmultipart/form-data uploads with text fields and file parts
  • Streaming — chunked downloads and streaming uploads without buffering
  • Chunk download — parallel HTTP Range requests for large files
  • HTTP upgrade — WebSocket and other protocol upgrades via HTTP/1.1 101 and HTTP/2 extended CONNECT (RFC 8441)
  • Request forwarding — proxy/gateway builder via client.forward(req) that strips hop-by-hop headers, rewrites URIs, streams bodies, auto-detects WebSocket upgrades, supports H2 extended CONNECT tunneling, per-forward h2c for gRPC upstreams, and adaptive h2c/h1 fallback cached by effective route and endpoint
  • HTTP Message Signatures — RFC 9421 request/response signing and verification, Accept-Signature, Content-Digest, trailer components, async signers, and forwarded message signing
  • Wasmtime host adapter — host-owned WASI HTTP forwarding via aioduct::wasmtime, with Tokio, smol, and compio transports plus explicit header policy controls
  • Blocking client — synchronous wrapper for non-async contexts (BlockingTokioClient, BlockingSmolClient, BlockingCompioClient)
  • Custom DNS — pluggable resolver via the Resolve trait; hickory-dns integration; DNS-over-HTTPS (doh feature) and DNS-over-TLS (dot feature)
  • HTTP/2 tuning — configurable window sizes, frame size, adaptive window, keepalive PINGs
  • Per-request h2cRequestBuilderSend and RequestBuilderLocal expose h2c_prior_knowledge(), while adaptive h2c probes and caches by effective route and endpoint so one client can mix h1 and h2c targets
  • Connection coalescing — reuses h2/h3 connections whose TLS certificate SANs cover the target domain (RFC 7540 §9.1.1), matching browser behavior
  • TCP keepalive — configurable keepalive interval for long-lived connections
  • TCP Fast Open — reduced connection latency on Linux via TCP_FASTOPEN_CONNECT
  • Local address binding — bind outgoing connections to a specific local IP
  • JSON — optional json feature for request/response serialization
  • Problem Details — RFC 9457 application/problem+json response parsing (requires json feature)
  • Happy Eyeballs — RFC 6555 connection racing, interleaves IPv6/IPv4 with 250ms stagger
  • Digest auth — automatic HTTP Digest authentication with 401 retry (RFC 7616, MD5)
  • Bandwidth limiter — token-bucket byte-rate throttle for download speed limiting
  • Netrc.netrc file parser and middleware for automatic credential injection
  • Auth helpers — bearer token, basic auth
  • Form data — URL-encoded form bodies
  • Query parameters — with percent-encoding
  • Default headers — automatic User-Agent, configurable defaults
  • Observability — optional tracing spans and OpenTelemetry middleware
  • Tower integration — use aioduct as a tower Service
  • Link headers — RFC 8288 Link header parsing for pagination and discovery
  • Forwarded header — RFC 7239 Forwarded header builder and parser
  • Request timings — per-phase timing via RequestObserver (DNS, TCP, TLS, TTFB, total)

Quick Start

[dependencies]
aioduct = { version = "0.2.5", features = ["tokio"] }
use aioduct::{TokioClient, StatusCode};

#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
    let client = TokioClient::new();

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

    assert_eq!(resp.status(), StatusCode::OK);
    println!("{}", resp.text().await?);
    Ok(())
}

HTTPS

Enable the rustls TLS backend plus exactly one rustls crypto provider:

aioduct = { version = "0.2.5", features = ["tokio", "rustls", "rustls-ring"] }

To use rustls with AWS-LC instead of ring, select the AWS-LC provider:

aioduct = { version = "0.2.5", features = ["tokio", "rustls", "rustls-aws-lc-rs"] }

To use the OS certificate store, add rustls-native-roots alongside either TLS provider:

aioduct = { version = "0.2.5", features = ["tokio", "rustls-native-roots", "rustls-aws-lc-rs"] }
use aioduct::TokioClient;

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

Feature Flags

Feature Description Stability
tokio Tokio async runtime Stable
smol Smol async runtime Stable
compio Compio runtime (io_uring / IOCP) Experimental
wasm Compatible browser/worker WASM via host Fetch API Experimental
wasi-p2 WASI Preview 2 guest HTTP client Experimental
wasmtime Host-side Wasmtime WASI HTTP adapter Experimental
rustls TLS via rustls; requires exactly one rustls provider Stable
rustls-ring ring crypto provider for rustls Stable
rustls-aws-lc-rs AWS-LC crypto provider for rustls Stable
rustls-native-roots Use OS certificate store with either rustls provider Stable
json JSON request/response with serde Stable
charset Charset decoding via encoding_rs Stable
gzip Gzip response decompression Stable
deflate Deflate response decompression Stable
brotli Brotli response decompression Stable
zstd Zstd response decompression Stable
blocking Synchronous wrapper for Tokio, smol, or compio clients Stable
hickory-dns DNS via hickory-resolver (requires tokio) Stable
doh DNS-over-HTTPS (implies hickory-dns) Stable
dot DNS-over-TLS (implies hickory-dns) Stable
tower Tower Service and Layer integration Stable
tracing Tracing spans for requests Stable
otel OpenTelemetry middleware Stable
precise-timing Use std::time::Instant for sub-millisecond timing Stable
http3 HTTP/3 via upstream h3 and quinn; requires Tokio, rustls, and one rustls provider Experimental

At least one runtime feature must be enabled or compilation will fail. When rustls is enabled, choose exactly one of rustls-ring or rustls-aws-lc-rs. The native-tls backend name is reserved for possible future OpenSSL/native TLS support and is not implemented today.

HTTP/3 deliberately follows upstream h3 rather than carrying a protocol fork. Deferred capabilities fail closed or use conservative replay behavior until they can be implemented with complete validation and stream-lifecycle guarantees; see the HTTP/3 limitations.

Examples

Runnable examples are organized by runtime. The dispatch and proxy examples below exercise complete request paths rather than configuration alone.

Scenario Tokio smol compio
Forward a real incoming multipart upload forward-multipart forward-multipart forward-multipart
Use one HTTP, HTTPS, or SOCKS proxy proxy-connect proxy-connect proxy-connect
Route through a two-hop proxy chain proxy-chain proxy-chain proxy-chain
Select proxies by target scheme and NO_PROXY proxy-routing proxy-routing proxy-routing
Compare buffered and one-shot retry behavior timeout-and-retry timeout-and-retry timeout-and-retry
Stream an HTTP/3 upload and fail closed http3-streaming-upload Not supported Not supported

JSON

// Requires features = ["tokio", "json"]
let resp = client.post("https://api.example.com/users")?
    .json(&serde_json::json!({"name": "Alice"}))?
    .send()
    .await?;

let user: User = resp.json().await?;

Form Data

let resp = client.post("https://example.com/login")?
    .form(&[("username", "admin"), ("password", "secret")])
    .send()
    .await?;

Authentication

// Bearer token
let resp = client.get("https://api.example.com/me")?
    .bearer_auth("my-token")
    .send()
    .await?;

// Basic auth
let resp = client.get("https://example.com/protected")?
    .basic_auth("user", Some("pass"))
    .send()
    .await?;

Query Parameters

let resp = client.get("https://example.com/search")?
    .query(&[("q", "hello world"), ("page", "1")])
    .send()
    .await?;
// GET /search?q=hello%20world&page=1

Client Configuration

use std::time::Duration;
use aioduct::TokioClient;

let client = TokioClient::builder()
    .timeout(Duration::from_secs(30))
    .max_redirects(5)
    .pool_idle_timeout(Duration::from_secs(90))
    .pool_max_lifetime(Duration::from_secs(600))
    .pool_max_idle_per_host(10)
    .pool_max_active_streams_per_connection(100)
    .tcp_keepalive(Duration::from_secs(60))
    .local_address("192.168.1.100".parse().unwrap())
    .build()?;

Proxy

use aioduct::{ProxyConfig, TokioClient};

// HTTP CONNECT proxy
let client = TokioClient::builder()
    .proxy(ProxyConfig::http("http://proxy.example.com:8080").unwrap())
    .build()?;

// HTTPS proxy (TLS-wrapped CONNECT)
let client = TokioClient::builder()
    .proxy(ProxyConfig::https("https://proxy.example.com:443").unwrap())
    .build()?;

// SOCKS5 proxy (local DNS — client resolves hostnames)
let client = TokioClient::builder()
    .proxy(ProxyConfig::socks5("socks5://proxy.example.com:1080").unwrap())
    .build()?;

// SOCKS5h proxy (remote DNS — proxy resolves hostnames)
let client = TokioClient::builder()
    .proxy(ProxyConfig::socks5h("socks5h://proxy.example.com:1080").unwrap())
    .build()?;

// SOCKS4/SOCKS4a proxy
let client = TokioClient::builder()
    .proxy(ProxyConfig::socks4("socks4a://proxy.example.com:1080").unwrap())
    .build()?;

Proxy URLs can include credentials, which are automatically extracted:

// Credentials embedded in the URL are parsed automatically
let proxy = ProxyConfig::http("http://user:pass@proxy.example.com:8080").unwrap();

For credentials from environment variables, use EnvCredentialResolver:

use aioduct::EnvCredentialResolver;

// Reads AIODUCT_PROXY_USER and AIODUCT_PROXY_PASS
let client = TokioClient::builder()
    .proxy_settings(
        ProxySettings::all(
            ProxyConfig::http("http://proxy:8080").unwrap()
        )
        .proxy_credential_resolver(EnvCredentialResolver),
    )
    .build()?;

Proxy chaining routes requests through up to 2 proxies in sequence:

use aioduct::{ProxyChain, ProxyConfig, TokioClient};

let chain = ProxyChain::new(vec![
    ProxyConfig::socks5("socks5://exit-proxy:1080").unwrap(),
    ProxyConfig::http("http://corporate-proxy:3128").unwrap(),
]);

let client = TokioClient::builder()
    .proxy_chain(chain)
    .build()?;

HTTP/2 Tuning

use aioduct::Http2Config;
use aioduct::TokioClient;

let client = TokioClient::builder()
    .tls(aioduct::tls::RustlsConnector::with_webpki_roots())
    .http2(
        Http2Config::new()
            .initial_stream_window_size(2 * 1024 * 1024)
            .adaptive_window(true)
            .keep_alive_interval(Duration::from_secs(20))
            .keep_alive_while_idle(true),
    )
    .build()?;

Smol Runtime

use aioduct::SmolClient;

smol::block_on(async {
    let client = SmolClient::new();
    let resp = client.get("http://httpbin.org/get")?
        .send()
        .await?;
    println!("{}", resp.text().await?);
    Ok::<_, aioduct::Error>(())
});

Request Forwarding (Reverse Proxy)

use aioduct::{TokioClient, Protocol};
use bytes::Bytes;
use http_body_util::Full;

# async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();

// Forward a request to an upstream, stripping a path prefix
let incoming_req = http::Request::builder()
    .method("GET")
    .uri("/api/users?page=2")
    .header("host", "proxy.example")
    .body(Full::new(Bytes::new()))
    .unwrap();

let resp = client
    .forward(incoming_req)
    .upstream("http://backend:8080".parse::<http::Uri>().unwrap())
    .strip_prefix("/api")
    .header(
        http::header::HeaderName::from_static("x-forwarded-for"),
        http::header::HeaderValue::from_static("10.0.0.1"),
    )
    .send()
    .await?;

// WebSocket upgrade forwarding (auto-detected from headers)
let ws_req = http::Request::builder()
    .method("GET")
    .uri("/ws/chat")
    .header("connection", "Upgrade")
    .header("upgrade", "websocket")
    .body(Full::new(Bytes::new()))
    .unwrap();

let resp = client
    .forward(ws_req)
    .upstream("http://ws-backend:9000".parse::<http::Uri>().unwrap())
    .send()
    .await?;

let upstream_io = resp.upgrade().await?;
// Splice with downstream: tokio::io::copy_bidirectional(...)
# Ok(())
# }

CLI Tools

The workspace includes two CLI tools built on aioduct:

aioduct-aria

An aria2-inspired parallel download tool. Splits large files into segments and downloads them concurrently using HTTP Range requests.

# Download with 8 segments
aioduct-aria -s 8 https://example.com/large-file.tar.gz

# Resume an interrupted download
aioduct-aria -c https://example.com/large-file.tar.gz

aioduct-curl

A curl-inspired HTTP tool with familiar flags.

# GET request
aioduct-curl https://httpbin.org/get

# POST with JSON body
aioduct-curl -X POST -d '{"key":"val"}' -H 'Content-Type: application/json' https://httpbin.org/post

# Follow redirects, basic auth, save to file
aioduct-curl -L -u user:pass -o output.html https://example.com

Both tools are workspace members (publish = false) and serve as real-world integration examples.

Architecture

HttpEngineSend<R: RuntimePoll, C: ConnectorSend>  ← tokio, smol (Send futures)
HttpEngineLocal<R: RuntimeLocal, C: ConnectorLocal>  ← compio (completion-based, !Send)
  ├── HttpEngineCore<B>       ← shared config (pool, timeouts, middleware, etc.)
  ├── RequestBuilderSend / RequestBuilderLocal
  │                           ← fluent APIs (headers, body, auth, query, timeout)
  ├── ConnectionPool          ← keyed by origin, protocol, route, address, H3 endpoint
  ├── TLS (rustls)            ← async handshake, ALPN → h1/h2; Quinn for h3
  ├── ConnectorSend / ConnectorLocal  ← pre-resolved socket connection and config
  └── Runtime traits
       ├── RuntimeCompletion  ← base: sleep and block_on
       ├── RuntimePoll        ← Send spawn (tokio, smol)
       └── RuntimeLocal       ← !Send spawn (compio)

Type aliases:
  TokioClient  = HttpEngineSend<TokioRuntime, tokio_rt::TcpConnector>
  SmolClient   = HttpEngineSend<SmolRuntime, smol_rt::TcpConnector>
  CompioClient = HttpEngineLocal<CompioRuntime, compio_rt::TcpConnector>

The runtime and connector responsibilities are split into separate traits. Their core signatures are shown below; connector socket-adoption helpers and the resolver's resolve_all default method are omitted for brevity.

/// Base runtime: timing and synchronous entry.
pub trait RuntimeCompletion: 'static {
    type Sleep: Future<Output = ()>;
    fn sleep(duration: Duration) -> Self::Sleep;
    fn block_on<F: Future>(future: F) -> Result<F::Output, aioduct::Error>;
}

/// Send-capable runtime: spawn Send futures (Tokio and smol).
pub trait RuntimePoll: RuntimeCompletion<Sleep: Send> + Send + Sync {
    fn spawn_send<F: Future<Output = ()> + Send + 'static>(future: F);
}

/// Local runtime: spawn futures that need not be Send (compio).
pub trait RuntimeLocal: RuntimeCompletion {
    fn spawn_local<F: Future<Output = ()> + 'static>(future: F);
}

/// Send connector for a pre-resolved socket address.
pub trait ConnectorSend: Clone + Send + Sync + 'static {
    type Stream: hyper::rt::Read
        + hyper::rt::Write
        + SocketConfig
        + Send
        + Unpin
        + 'static;
    fn connect(&self, addr: SocketAddr)
        -> impl Future<Output = io::Result<Self::Stream>> + Send;
}

/// Local connector for completion-based runtimes.
pub trait ConnectorLocal: 'static {
    type Stream: hyper::rt::Read
        + hyper::rt::Write
        + SocketConfig
        + Unpin
        + 'static;
    async fn connect(&self, addr: SocketAddr) -> io::Result<Self::Stream>;
}

/// Pluggable DNS resolution.
pub trait Resolve: Send + Sync + 'static {
    fn resolve(&self, host: &str, port: u16)
        -> Pin<Box<dyn Future<Output = io::Result<SocketAddr>> + Send>>;
}

Comparison

reqwest aioduct
hyper 1.x via hyper-util legacy 1.x direct
hyper-util Required Not used
Runtime tokio only tokio / smol / compio / wasm / wasi
TLS rustls or native-tls rustls (native-tls reserved for future support)
HTTP/3 Experimental Experimental
io_uring No Via compio
Connection pool hyper-util legacy Custom h1/h2/h3
Cookie jar Yes Yes
SSE streaming No (manual) Built-in
Rate limiting No Built-in
HTTP caching No Built-in
HSTS No Built-in
Link headers No Built-in
Problem Details No Built-in
Middleware Via tower Built-in + tower
Happy Eyeballs No RFC 6555
Digest auth No Built-in
Bandwidth limiter No Built-in
Netrc No Built-in
Request timings No Observer
Connection coalescing No Built-in (RFC 7540)
DNS-over-HTTPS/TLS No Built-in
HTTP/3 0-RTT No Unsupported
Request forwarding No Built-in

MSRV

The minimum supported Rust version is 1.95.0 (edition 2024).

License

Licensed under either of

at your option.