Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

aioduct is an async-native Rust HTTP client built directly on hyper 1.x — with no hyper-util dependency and no legacy APIs.

Motivation

The Rust HTTP client ecosystem has a gap:

  • reqwest depends on hyper-util’s legacy::Client, which wraps hyper 0.x-style patterns over hyper 1.x. It carries years of backwards-compatibility baggage.
  • hyper-util itself labels its client as “legacy” — the hyper team acknowledges it’s not the right long-term answer.
  • hyper 1.x was redesigned to be a minimal HTTP protocol engine with clean connection-level primitives (hyper::client::conn::http1, hyper::client::conn::http2), but no production client uses it this way today.

aioduct fills this gap: a production-quality HTTP client that 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.

Design Principles

  1. No hyper-util — custom executor and IO adapters directly against hyper::rt traits. ~50 lines each, zero legacy baggage.
  2. No default runtime — the core crate is pure types, traits, and logic. Opt into a runtime via feature flags.
  3. No default TLS — plain HTTP works out of the box. Enable rustls for HTTPS.
  4. Runtime-agnostic coreHttpEngineSend<R, C> and HttpEngineLocal<R, C> are generic over runtime and connector traits. All pool, TLS, and HTTP logic works with any conforming runtime.
  5. HTTP/3 as experimental — h3 + h3-quinn behind a feature flag.

Comparison with reqwest

Featurereqwestaioduct
hyper version1.x via hyper-util legacy1.x direct
hyper-utilRequiredNot used
Runtimetokio onlytokio / smol / compio / wasm
TLSrustls or native-tlsrustls (native-tls reserved)
HTTP/3ExperimentalExperimental
io_uringNoVia compio feature
Connection poolhyper-util legacyCustom, built for h1/h2/h3
Cookie jarYesYes
SSE streamingNo (manual)Built-in
Rate limitingNoBuilt-in
HTTP cachingNoBuilt-in
MiddlewareVia towerBuilt-in + tower
Happy EyeballsNoRFC 6555
Digest authNoBuilt-in
Bandwidth limiterNoBuilt-in
NetrcNoBuilt-in
Request timingsNoBuilt-in

Getting Started

Installation

Add aioduct to your Cargo.toml with at least one runtime feature:

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio"] }

For HTTPS support, add the rustls backend and exactly one rustls crypto provider:

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring"] }

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

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-aws-lc-rs"] }

For JSON serialization/deserialization:

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "json"] }

Quick Example

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);
    let body = resp.text().await?;
    println!("{body}");
    Ok(())
}

HTTPS with rustls

use aioduct::TokioClient;

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

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

    println!("status: {}", resp.status());
    Ok(())
}

Sending JSON

Requires the json feature.

use aioduct::TokioClient;
use serde::{Deserialize, Serialize};

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

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

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

    let resp = client
        .post("https://api.example.com/users")?
        .json(&CreateUser {
            name: "Alice".into(),
            email: "alice@example.com".into(),
        })?
        .send()
        .await?;

    let user: User = resp.json().await?;
    println!("created user {} with id {}", user.name, user.id);
    Ok(())
}

Using the smol Runtime

use aioduct::SmolClient;

fn main() -> Result<(), aioduct::Error> {
    smol::block_on(async {
        let client = SmolClient::new();

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

        println!("status: {}", resp.status());
        Ok(())
    })
}

Client Configuration

#![allow(unused)]
fn main() {
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_idle_per_host(10)
    .build()?;
}

Feature Flags

aioduct uses feature flags to control runtime, TLS, and serialization dependencies. The default feature set is empty — you must enable at least one runtime.

Available Features

FeatureDependenciesStabilityDescription
tokiotokioStableTokio async runtime
smolsmol, async-io, futures-ioStableSmol async runtime
compiocompio-runtime, async-ioExperimentalCompio runtime (io_uring / IOCP)
wasmwasm-bindgen, web-sys, js-sysExperimentalBrowser/WASM runtime
rustlsrustls, webpki-roots, rustls-pemfileStableTLS backend via rustls; requires exactly one rustls provider
rustls-ringrustls ring providerStableRing crypto provider for rustls
rustls-aws-lc-rsrustls AWS-LC providerStableAWS-LC crypto provider for rustls
rustls-native-rootsrustls-native-certsStableUse OS certificate store with either rustls provider
jsonserde, serde_json, serde_urlencodedStableJSON request/response helpers
charsetencoding_rs, mimeStableCharset decoding for response text
gzipflate2StableGzip response decompression
deflateflate2StableDeflate response decompression
brotlibrotliStableBrotli response decompression
zstdzstdStableZstd response decompression
blockingtokioStableSynchronous blocking client wrapper
hickory-dnshickory-resolver, tokioStableDNS resolution via hickory
dohhickory-resolver (https)StableDNS-over-HTTPS (implies hickory-dns)
dothickory-resolver (tls)StableDNS-over-TLS (implies hickory-dns)
towertower-service, tower-layerStableTower Service/Layer integration
tracingtracingStableTracing spans for HTTP requests
otelopentelemetry, opentelemetry-httpStableOpenTelemetry middleware
http3h3, h3-quinn, quinnExperimentalHTTP/3 transport; currently requires rustls plus one rustls provider

TLS Provider Features

Use rustls for the HTTPS backend and choose exactly one rustls crypto provider: rustls-ring or rustls-aws-lc-rs. The backend and provider flags are separate so future TLS backends, such as a reserved native-tls/OpenSSL backend, can compose with higher-level HTTP features without changing the rustls provider model. rustls-native-roots is provider-neutral: it enables the rustls backend and composes with either provider.

Compile Error Without Runtime

If no runtime feature is selected, aioduct emits a compile error:

error: aioduct: enable at least one runtime feature: tokio, smol, compio, or wasm

Common Feature Combinations

# HTTP only, tokio runtime
aioduct = { version = "0.2.0-alpha.1", features = ["tokio"] }

# HTTPS + JSON, tokio runtime
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "json"] }

# HTTPS with AWS-LC, tokio runtime
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-aws-lc-rs"] }

# HTTPS with AWS-LC and OS native roots
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls-native-roots", "rustls-aws-lc-rs"] }

# HTTP only, smol runtime
aioduct = { version = "0.2.0-alpha.1", features = ["smol"] }

# HTTPS, smol runtime
aioduct = { version = "0.2.0-alpha.1", features = ["smol", "rustls", "rustls-ring"] }

# HTTP only, compio runtime (experimental)
aioduct = { version = "0.2.0-alpha.1", features = ["compio"] }

# HTTPS + JSON + compression, tokio runtime
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "json", "gzip", "brotli", "zstd", "deflate"] }

# Blocking client (synchronous)
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "blocking"] }

# With tracing and OpenTelemetry
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "tracing", "otel"] }

# With tower integration
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "tower"] }

# Hickory DNS resolver
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "hickory-dns"] }

# DNS-over-HTTPS
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "doh"] }

# DNS-over-TLS
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "dot"] }

# HTTP/3 with ring
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "http3", "rustls", "rustls-ring"] }

# HTTP/3 with AWS-LC
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "http3", "rustls", "rustls-aws-lc-rs"] }

Core Dependencies (Always Included)

These are pulled in regardless of feature flags:

  • hyper 1.x — HTTP/1.1 and HTTP/2 protocol engine
  • http — Standard HTTP types (Method, StatusCode, HeaderMap, etc.)
  • http-body-util — Body combinators for hyper
  • bytes — Zero-copy byte buffers
  • pin-project-lite — Safe pin projections
  • thiserror — Error derive macros
  • base64 — Base64 encoding for basic auth
  • percent-encoding — URL percent-encoding for query params and forms

Architecture

Module Layout

src/
  lib.rs              # Re-exports, compile_error gate, type aliases
  error.rs            # Error enum, type aliases
  engine.rs           # HttpEngineCore<B>, HttpEngineSend<R,C>, HttpEngineLocal<R,C>
  engine_builder.rs   # HttpEngineBuilder<R,C> — fluent client configuration
  request.rs          # RequestBuilderSend<R,C>, RequestBuilderLocal<R,C>
  response.rs         # ResponseBodySend, ResponseBodyLocal — status, headers, body consumption
  body.rs             # BodyStream, RequestBody (buffered/streaming)
  timeout.rs          # Pin-projected Timeout future
  cookie.rs           # CookieJar, Cookie, Set-Cookie parsing
  cache.rs            # HttpCache, CacheConfig — in-memory HTTP cache
  retry.rs            # RetryConfig, RetryBudget — exponential backoff
  throttle.rs         # RateLimiter — token-bucket rate limiting
  bandwidth.rs        # BandwidthLimiter — byte-rate download throttle
  redirect.rs         # RedirectPolicy, RedirectAction
  middleware.rs       # Middleware trait
  digest_auth.rs      # DigestAuth — HTTP Digest challenge-response
  netrc.rs            # Netrc, NetrcMiddleware — .netrc credential injection
  happy_eyeballs.rs   # RFC 6555 IPv6/IPv4 connection racing
  sse.rs              # SseStream, SseEvent — Server-Sent Events
  multipart.rs        # Multipart, Part — multipart/form-data
  chunk_download.rs   # ChunkDownload — parallel range requests
  upgrade.rs          # Upgraded — HTTP/1.1 protocol upgrade
  decompress.rs       # DecompressBody — gzip/brotli/zstd/deflate
  proxy.rs            # ProxyConfig, ProxySettings, NoProxy
  socks4.rs           # SOCKS4/4a handshake
  socks5.rs           # SOCKS5 handshake
  blocking.rs         # Blocking client wrapper (requires tokio)
  traits.rs           # HttpClient, RequestBuilderExt, ResponseExt, ByteStreamExt
  runtime/
    mod.rs            # RuntimeCompletion, RuntimePoll, RuntimeLocal traits
    tokio_rt.rs       # TokioRuntime, TcpConnector, TokioIo
    smol_rt.rs        # SmolRuntime, TcpConnector, SmolIo
    compio_rt.rs      # CompioRuntime, TcpConnector
  connector.rs        # ConnectorSend, ConnectorLocal traits, SocketConfig
  pool/
    mod.rs            # ConnectionPool — keyed pooling
    connection.rs     # PooledConnection, HttpConnection enum
  tls/
    mod.rs            # TlsConnect trait, re-exports
    rustls_connector.rs  # RustlsConnector, TlsStream, ALPN
  h3/
    mod.rs            # HTTP/3 transport (experimental)
  http2.rs            # Http2Config
  hickory.rs          # HickoryResolver (requires hickory-dns)
  wasm/               # WASM/browser runtime

Request Flow

A request in aioduct goes through these stages:

client.get("http://example.com/path")?
  -> RequestBuilderSend (accumulate headers, body, timeout, query params)
  -> RequestBuilderSend::send()
    -> apply timeout wrapper (Timeout future)
    -> rate limiter wait (if configured)
    -> check HTTP cache (if configured, return cached response on hit)
    -> HttpEngineCore::execute()
      -> merge default headers
      -> apply cookie jar cookies (if configured)
      -> retry loop (if configured):
        -> redirect loop (up to max_redirects):
          -> run middleware on_request hooks
          -> build http::Request with method, path-only URI, headers
          -> execute_single()
            -> pool checkout (reuse existing connection?)
            -> if miss: ConnectorSend::connect(&SocketConfig)
              -> TCP connect -> TLS handshake (if HTTPS)
            -> ALPN -> select h1 or h2 sender
            -> send request on connection
            -> pool checkin
          -> digest auth retry (if 401 + WWW-Authenticate: Digest)
          -> run middleware on_response hooks
          -> store response cookies in jar (if configured)
          -> check redirect status -> follow or return
      -> cache response (if configured and cacheable)
      -> decompress body (if content-encoding matches)
      -> apply bandwidth limiter (if configured)
  -> ResponseBodySend

Key Design Decisions

No hyper-util

hyper 1.x provides raw connection-level primitives. hyper-util wraps them in a legacy Client that mimics hyper 0.x behavior. aioduct skips hyper-util entirely and implements:

  • IO adapters (TokioIo, SmolIo): Bridge runtime-specific AsyncRead/AsyncWrite to hyper::rt::Read/hyper::rt::Write. Each is ~50 lines of unsafe pin projection.
  • HyperExecutor: A generic executor that delegates spawn to the active Runtime. Uses PhantomData<fn() -> R> (not PhantomData<R>) to ensure it is always Unpin, which hyper’s h2 handshake requires.

Split Engine Types: Send vs Local

The v0.2 architecture splits the client into two engine types to cleanly support both poll-based and completion-based runtimes:

  • HttpEngineSend<R: RuntimePoll, C: ConnectorSend> — for runtimes where futures are Send (tokio, smol). The connector produces streams that are Send, enabling work-stealing schedulers.
  • HttpEngineLocal<R: RuntimeLocal, C: ConnectorLocal> — for thread-per-core runtimes (compio) where futures are !Send. The connector produces streams that stay on the local thread.

Both share HttpEngineCore<B> for configuration state (pool settings, timeouts, middleware, TLS, etc.), minimizing code duplication.

Connector Abstraction

Networking is decoupled from the runtime via connector traits:

  • ConnectorSend: Clone + Send + Sync + 'static, connects asynchronously, returns a Send stream.
  • ConnectorLocal: 'static, connects asynchronously, returns a !Send stream.

Each runtime module provides a default TcpConnector that implements the appropriate trait. Users can supply custom connectors for testing, proxying, or alternative transports.

Generic over Runtime

HttpEngineSend<R, C> and HttpEngineLocal<R, C> carry the runtime and connector as type parameters rather than using dynamic dispatch. This means:

  • Zero-cost abstraction — no vtable overhead
  • All runtime-specific code is monomorphized away
  • The compiler can inline across the runtime boundary

Portable Traits

The HttpClient, RequestBuilderExt, ResponseExt, and ByteStreamExt traits provide a common interface that works across both Send and Local engine variants, enabling generic code that is runtime-agnostic.

Connection Pool

The pool is keyed by (scheme, authority) and stores connections in a VecDeque per key. On checkout, expired connections are evicted. On checkin, the pool respects max_idle_per_host. HTTP/2 connections can be shared across concurrent requests since h2 multiplexes streams.

TLS State Machine

The rustls integration implements an async TLS handshake as a manual state machine. Because rustls::ClientConnection expects synchronous std::io::Read/Write, the adapter uses helper functions that wrap async streams and return WouldBlock when the underlying stream would block. This avoids spawning a blocking task or using a separate thread for the handshake.

Timeout via Pin Projection

The Timeout type is a pin-projected enum with two variants:

  • NoTimeout { future } — passes through directly
  • WithTimeout { future, sleep } — polls both; if sleep completes first, returns Error::Timeout

This avoids tokio::select! or any runtime-specific timeout mechanism, keeping the implementation runtime-agnostic.

Runtime and Connector Traits

aioduct is runtime-agnostic. The runtime and connector traits define the minimal interfaces that an async runtime and its networking layer must provide.

Runtime Trait Hierarchy

The runtime system is split into a three-level trait hierarchy, from most general to most capable:

#![allow(unused)]
fn main() {
pub trait RuntimeCompletion: 'static {
    fn block_on<F: Future>(future: F) -> F::Output;
}

pub trait RuntimePoll: RuntimeCompletion {
    fn spawn_send<F>(future: F)
    where
        F: Future<Output = ()> + Send + 'static;

    fn sleep(duration: Duration) -> impl Future<Output = ()> + Send;
}

pub trait RuntimeLocal: RuntimeCompletion {
    fn spawn_local<F>(future: F)
    where
        F: Future<Output = ()> + 'static;

    fn sleep(duration: Duration) -> impl Future<Output = ()>;
}
}

RuntimeCompletion (Base)

The foundation trait. Provides block_on to drive a future to completion synchronously. Every runtime implements this.

RuntimePoll (Send-capable runtimes)

Extends RuntimeCompletion with:

  • spawn_send: Spawn a Send future as a detached background task. Used for driving hyper connection futures on work-stealing schedulers.
  • sleep: Create a Send sleep future for the given duration. Used for timeouts and pool idle eviction.

Implemented by TokioRuntime and SmolRuntime.

RuntimeLocal (Thread-local runtimes)

Extends RuntimeCompletion with:

  • spawn_local: Spawn a !Send future on the current thread. Used for thread-per-core runtimes where tasks never cross thread boundaries.
  • sleep: Create a sleep future (not required to be Send).

Implemented by CompioRuntime.

Connector Traits

Networking is decoupled from the runtime via connector traits. Each connector is responsible for establishing a TCP connection given a SocketConfig.

#![allow(unused)]
fn main() {
pub trait ConnectorSend: Clone + Send + Sync + 'static {
    type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;

    fn connect(&self, config: &SocketConfig) -> impl Future<Output = io::Result<Self::Stream>> + Send;
}

pub trait ConnectorLocal: 'static {
    type Stream: AsyncRead + AsyncWrite + Unpin + 'static;

    fn connect(&self, config: &SocketConfig) -> impl Future<Output = io::Result<Self::Stream>>;
}
}

ConnectorSend

For use with HttpEngineSend<R, C>. Must be Clone + Send + Sync so it can be shared across tasks on a work-stealing scheduler. The returned stream must be Send.

ConnectorLocal

For use with HttpEngineLocal<R, C>. No Send bounds — the connector and its streams live on a single thread.

SocketConfig

Both connector traits receive a SocketConfig that contains the target address, port, DNS resolution hints, TCP options (nodelay, keepalive), and local bind address.

Built-in Implementations

TokioRuntime + TcpConnector

Enabled with features = ["tokio"].

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::new();
}
  • TokioRuntime implements RuntimePoll using tokio::runtime::Handle::block_on, tokio::spawn, and tokio::time::sleep.
  • tokio_rt::TcpConnector implements ConnectorSend using tokio::net::TcpStream. Sets TCP_NODELAY by default.
  • The TokioIo adapter bridges tokio’s AsyncRead/AsyncWrite to hyper’s rt::Read/rt::Write.

SmolRuntime + TcpConnector

Enabled with features = ["smol"].

#![allow(unused)]
fn main() {
use aioduct::SmolClient;

let client = SmolClient::new();
}
  • SmolRuntime implements RuntimePoll using smol::block_on, smol::spawn, and async_io::Timer.
  • smol_rt::TcpConnector implements ConnectorSend using smol::net::TcpStream.
  • The SmolIo adapter bridges futures_io::AsyncRead/AsyncWrite to hyper’s traits.

CompioRuntime + TcpConnector (Experimental)

Enabled with features = ["compio"].

#![allow(unused)]
fn main() {
use aioduct::CompioClient;

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

Compio is a completion-based I/O runtime (io_uring on Linux, IOCP on Windows) with a thread-per-core execution model.

  • CompioRuntime implements RuntimeLocal using compio_runtime::block_on, compio_runtime::spawn, and compio’s native timers.
  • compio_rt::TcpConnector implements ConnectorLocal. Streams are !Send since they are bound to the completion ring of the current thread.

Important: compio futures are !Send (they cannot be sent between threads). The CompioClient type alias uses HttpEngineLocal, which does not require Send bounds on futures or streams. This is safe because compio’s thread-per-core model guarantees futures never cross thread boundaries.

HyperExecutor

hyper’s HTTP/2 handshake requires an Executor to spawn background tasks for connection management. aioduct provides a generic HyperExecutor<R> that delegates to R::spawn_send (for RuntimePoll) or R::spawn_local (for RuntimeLocal):

#![allow(unused)]
fn main() {
pub struct HyperExecutor<R>(PhantomData<fn() -> R>);
}

The PhantomData<fn() -> R> (rather than PhantomData<R>) ensures HyperExecutor is always Unpin regardless of R, which hyper’s h2 handshake requires.

Implementing a Custom Runtime

To add a new poll-based runtime:

  1. Implement RuntimeCompletion and RuntimePoll for your runtime marker type.
  2. Implement ConnectorSend for a connector struct that establishes TCP connections using your runtime’s networking primitives.
  3. Provide an IO adapter that implements hyper::rt::Read and hyper::rt::Write by delegating to your runtime’s native async IO traits.

For a thread-local runtime, implement RuntimeCompletion and RuntimeLocal instead, with a ConnectorLocal implementation.

See src/runtime/tokio_rt.rs for a reference RuntimePoll + ConnectorSend implementation.

TLS & HTTPS

aioduct supports HTTPS via rustls. No TLS library is included by default — plain HTTP works without any TLS dependency.

Enabling HTTPS

Use the rustls TLS backend with the ring crypto provider:

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring"] }

Use the same rustls backend with the AWS-LC crypto provider:

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-aws-lc-rs"] }

Add rustls-native-roots alongside either provider to use the OS certificate store:

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls-native-roots", "rustls-aws-lc-rs"] }

Quick Start

use aioduct::TokioClient;

#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
    // with_rustls() configures WebPKI root certificates automatically
    let client = TokioClient::with_rustls();

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

    println!("status: {}", resp.status());
    Ok(())
}

How It Works

Handshake

The TLS handshake is fully async, implemented as a manual state machine:

  1. RustlsConnector wraps a rustls::ClientConfig (with ALPN protocols h2 and http/1.1)
  2. On connect, a TlsStream<S> is created with the underlying TCP stream and a rustls::ClientConnection
  3. The handshake drives read_tls/write_tls helper functions that wrap the async stream as synchronous std::io::Read/Write, using WouldBlock for flow control
  4. Once complete, the negotiated ALPN protocol determines whether to use HTTP/1.1 or HTTP/2

ALPN Negotiation

After the TLS handshake, the negotiated protocol is inspected:

  • h2 → uses hyper::client::conn::http2::handshake
  • http/1.1 (or no ALPN) → uses hyper::client::conn::http1::handshake

This happens transparently — the client automatically selects the best protocol for each connection.

Root Certificates

TokioClient::with_rustls() uses webpki-roots, which bundles Mozilla’s root certificate store directly in the binary. No system certificate store access is needed.

Enable rustls-native-roots to build the connector from the operating system certificate store instead. This feature enables the rustls backend but does not select a crypto provider by itself; combine it with either rustls-ring or rustls-aws-lc-rs.

Crypto Providers

The rustls feature enables the rustls TLS backend, while rustls-ring and rustls-aws-lc-rs select the crypto provider. Enable exactly one provider whenever rustls is enabled; enabling neither or both is a compile error.

The backend/provider split keeps room for future TLS backends. A native-tls backend name is reserved for possible OpenSSL/native TLS support, but it is not implemented today.

Custom TLS Configuration

For advanced use cases, configure the RustlsConnector directly:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;

let client = TokioClient::builder()
    .tls(RustlsConnector::with_webpki_roots())
    .build()?;
}

Accepting Invalid Certificates

For development and testing, you can disable certificate verification:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::builder()
    .danger_accept_invalid_certs()
    .build()?;
}

Warning: Never use this in production. It disables all certificate verification, making the connection vulnerable to MITM attacks.

HTTPS-Only Mode

To enforce that all requests use HTTPS:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;

let client = TokioClient::builder()
    .tls(RustlsConnector::with_webpki_roots())
    .https_only(true)
    .build()?;

// This will return an error:
// client.get("http://example.com")?.send().await?;
}

Error Handling

TLS errors surface as Error::Tls(Box<dyn std::error::Error + Send + Sync>). Common failure modes:

  • Certificate verification failure (expired, wrong hostname, untrusted CA)
  • No TLS connector configured (HTTPS URL without the rustls backend and a rustls provider, or without a .tls() builder call for a custom connector)
  • Handshake timeout (use .timeout() on the request or client)

Connection Pool

aioduct maintains a connection pool to reuse TCP (and TLS) connections across requests to the same origin, avoiding the overhead of repeated handshakes.

How It Works

Pool Key

Connections are keyed by (scheme, authority) — for example, (https, api.example.com:443). Two requests to the same origin share pooled connections; requests to different origins use separate pools.

Lifecycle

  1. Checkout: When a request is made, the pool checks for an existing idle connection to the target origin. It uses LIFO ordering (most recently returned first) to prefer the freshest connections. Each candidate is checked for readiness — if a connection is stale or closed, it’s discarded and the next one is tried.
  2. Send: The request is sent on the connection (either reused or freshly established).
  3. Checkin: After the response headers are received, the connection is returned to the pool. When at capacity, the oldest idle connection is evicted to make room for the new one.

Idle Eviction

Connections are evicted in three ways:

  • On checkout: Expired connections (past idle timeout) are discarded while searching for a ready one.
  • On checkin: When the per-host queue is full, the oldest connection is evicted.
  • Background reaper: A periodic background task runs at the idle timeout interval and removes all expired connections, preventing memory leaks from unused hosts.

HTTP/2 Multiplexing

HTTP/2 connections support multiplexing — multiple concurrent requests share a single connection. The pool tracks the hyper SendRequest handle, which naturally supports this. When an h2 connection is checked out, it remains usable by other requests concurrently.

HTTP/3 (QUIC) Pooling

When the http3 feature is enabled with the rustls backend and one rustls provider, QUIC connections are pooled alongside TCP connections. Like HTTP/2, HTTP/3 multiplexes streams over a single connection, so a pooled QUIC connection can serve multiple sequential requests to the same origin without re-establishing the handshake. The pool uses the same (scheme, authority) key for both TCP and QUIC connections.

Configuration

#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::TokioClient;

let client = TokioClient::builder()
    .pool_idle_timeout(Duration::from_secs(90))  // default: 90s
    .pool_max_idle_per_host(10)                   // default: 10
    .build()?;
}

Options

OptionDefaultDescription
pool_idle_timeout90sHow long an idle connection is kept before eviction
pool_max_idle_per_host10Maximum idle connections per (scheme, authority)

Connection Health

On checkout, the pool verifies each candidate connection is still ready using hyper’s SendRequest::is_ready(). If a connection has been closed by the server (e.g., due to keep-alive timeout), it’s discarded and the next pooled connection is tried. If no ready connection is found, a new one is established.

Connection Coalescing

When enabled (default), aioduct reuses h2/h3 connections for different hostnames that share the same TLS certificate, matching browser behavior per RFC 7540 §9.1.1.

How It Works

  1. When a new request has no pooled connection for its origin, the pool scans existing h2/h3 connections.
  2. If a connection’s TLS certificate includes the target hostname in its Subject Alternative Names (SANs), and the resolved IP address matches the connection’s remote address, the connection is reused.
  3. This avoids a redundant TLS handshake and TCP/QUIC connection for hosts that share infrastructure (e.g., api.example.com and cdn.example.com on the same certificate).

Configuration

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

// Enabled by default; disable if needed:
let client = TokioClient::builder()
    .connection_coalescing(false)
    .build()?;
}

Requirements

  • Only applies to h2 and h3 connections (HTTP/1.1 doesn’t multiplex).
  • Requires the rustls feature (SANs are extracted from the peer certificate).
  • Both SAN match and IP match are required — this prevents coalescing across servers that happen to share a wildcard certificate but serve different content.

Server-Sent Events (SSE)

aioduct has built-in support for consuming Server-Sent Events streams. SSE is a standard for servers to push events to clients over HTTP, commonly used by LLM APIs (OpenAI, Anthropic) for streaming responses.

Basic Usage

use aioduct::TokioClient;

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

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

    let mut sse = resp.into_sse_stream();
    while let Some(event) = sse.next().await {
        let event = event?;
        println!("event: {:?}, data: {}", event.event, event.data);
    }

    Ok(())
}

SseEvent Fields

Each parsed event contains:

FieldTypeDescription
eventOption<String>Event type (from event: field)
dataStringEvent payload (joined with \n for multi-line)
idOption<String>Event ID (from id: field)
retryOption<u64>Reconnection time in ms (from retry: field)

SSE Wire Format

The SSE protocol uses a simple text-based format where events are separated by blank lines (\n\n):

event: greeting
data: hello

data: line1
data: line2

event: done
data: bye
id: 42
retry: 5000

This produces three events:

  1. SseEvent { event: Some("greeting"), data: "hello", id: None, retry: None }
  2. SseEvent { event: None, data: "line1\nline2", id: None, retry: None }
  3. SseEvent { event: Some("done"), data: "bye", id: Some("42"), retry: Some(5000) }

Comments

Lines starting with : are comments and are silently ignored:

: this is a heartbeat comment
data: actual event

Example: Streaming LLM API

use aioduct::TokioClient;

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

    let resp = client
        .post("https://api.example.com/v1/chat/completions")?
        .bearer_auth("sk-...")
        .header_str("content-type", "application/json")?
        .body(r#"{"model":"gpt-4","stream":true,"messages":[{"role":"user","content":"Hi"}]}"#)
        .send()
        .await?;

    let mut sse = resp.into_sse_stream();
    while let Some(event) = sse.next().await {
        let event = event?;
        if event.data == "[DONE]" {
            break;
        }
        print!("{}", event.data);
    }

    Ok(())
}

Retry with Backoff

aioduct supports automatic retries with configurable exponential backoff. Retries can be set at the client level (applied to all requests) or per-request.

Basic Usage

use std::time::Duration;
use aioduct::{TokioClient, RetryConfig};

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

    let resp = client
        .get("http://example.com/api")?
        .retry(RetryConfig::default())
        .send()
        .await?;

    println!("status: {}", resp.status());
    Ok(())
}

RetryConfig

FieldTypeDefaultDescription
max_retriesu323Maximum number of retry attempts
initial_backoffDuration100msDelay before the first retry
max_backoffDuration30sUpper bound on backoff delay
backoff_multiplierf642.0Multiplier applied to backoff each attempt
retry_on_statusbooltrueWhether to retry on 5xx server errors
budgetOption<RetryBudget>NoneToken-bucket budget to prevent retry storms

The delay for attempt n (0-indexed) is:

delay = min(initial_backoff * multiplier^n, max_backoff)

What Gets Retried

By default, aioduct retries on:

  • Connection errors — I/O errors, hyper transport errors
  • Timeouts — request-level or client-level timeout exceeded
  • 5xx server errors — 500, 502, 503, etc. (when retry_on_status is true)
  • 429 Too Many Requests — rate limiting responses (when retry_on_status is true)

Client errors (4xx, other than 429) are never retried. To disable status-based retry, set retry_on_status(false).

Retry-After Header

When a server responds with a Retry-After header (common on 429 and 503 responses), aioduct uses the server’s requested delay instead of its own exponential backoff for that attempt. Both formats are supported:

  • Seconds: Retry-After: 120 — wait 120 seconds
  • HTTP-date: Retry-After: Wed, 21 Oct 2026 07:28:00 GMT — wait until the specified time

If the Retry-After value is missing or unparseable, the normal backoff delay is used.

Retry Budget

A RetryBudget prevents retry storms by limiting the total retry rate across all requests. Each successful (non-retried) request deposits tokens; each retry attempt withdraws one. When the budget is exhausted, retries are suppressed.

#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::{TokioClient, RetryConfig, RetryBudget};

let client = TokioClient::builder()
    .retry(
        RetryConfig::default()
            .budget(RetryBudget::new(10, 1)),  // max 10 tokens, +1 per success
    )
    .build()?;
}

Client-Level Retry

Set a default retry policy for all requests:

#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::{TokioClient, RetryConfig};

let client = TokioClient::builder()
    .retry(
        RetryConfig::default()
            .max_retries(5)
            .initial_backoff(Duration::from_millis(200))
            .max_backoff(Duration::from_secs(10)),
    )
    .build()?;
}

Per-Request Override

A retry config on a request takes precedence over the client default:

#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::{TokioClient, RetryConfig};
let client = TokioClient::new();
let resp = client
    .post("http://example.com/idempotent-endpoint")?
    .retry(RetryConfig::default().max_retries(1))
    .body("payload")
    .send()
    .await?;
Ok::<_, aioduct::Error>(())
}

Example: Resilient LLM API Client

use std::time::Duration;
use aioduct::{TokioClient, RetryConfig};

#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
    let client = TokioClient::builder()
        .retry(
            RetryConfig::default()
                .max_retries(3)
                .initial_backoff(Duration::from_millis(500))
                .backoff_multiplier(2.0),
        )
        .timeout(Duration::from_secs(30))
        .build()?;

    let resp = client
        .post("https://api.example.com/v1/chat/completions")?
        .bearer_auth("sk-...")
        .header_str("content-type", "application/json")?
        .body(r#"{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}"#)
        .send()
        .await?;

    println!("{}", resp.text().await?);
    Ok(())
}

Redirect Policy

aioduct follows HTTP redirects automatically by default (up to 10 hops). You can customize this behavior with RedirectPolicy.

Policies

PolicyBehavior
RedirectPolicy::default()Follow up to 10 redirects
RedirectPolicy::none()Never follow redirects — return the 3xx response as-is
RedirectPolicy::limited(n)Follow up to n redirects
RedirectPolicy::custom(fn)User callback decides per-redirect

Method Handling

Regardless of policy, aioduct follows RFC semantics for method changes:

  • 301, 302, 303 → method changes to GET, body is dropped, content headers (Content-Type, Content-Length, Content-Encoding) are stripped
  • 307, 308 → method and body are preserved

Sensitive headers (Authorization, Cookie, Proxy-Authorization) are automatically stripped when redirecting to a different origin.

No Redirects

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, RedirectPolicy};

let client = TokioClient::builder()
    .redirect_policy(RedirectPolicy::none())
    .build()?;
}

Limited Redirects

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, RedirectPolicy};

// Also available via the shorthand:
let client = TokioClient::builder()
    .max_redirects(5)
    .build()?;

// Equivalent to:
let client = TokioClient::builder()
    .redirect_policy(RedirectPolicy::limited(5))
    .build()?;
}

Custom Policy

The custom callback receives the current URI, next (redirect target) URI, status code, and HTTP method. Return RedirectAction::Follow to follow the redirect, or RedirectAction::Stop to stop and return the redirect response.

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, RedirectAction, RedirectPolicy};

let client = TokioClient::builder()
    .redirect_policy(RedirectPolicy::custom(|current, next, status, method| {
        // Only follow redirects that stay on the same host
        if current.host() == next.host() {
            RedirectAction::Follow
        } else {
            RedirectAction::Stop
        }
    }))
    .build()?;
}

Use Cases for Custom Policies

  • Same-origin only: prevent redirects to external domains
  • HTTPS-only: reject downgrades from HTTPS to HTTP
  • Logging: log each redirect decision while still following
  • Domain allowlist: only follow redirects to trusted domains

Referer Header

By default, aioduct does not set a Referer header on redirect hops. Enable it on the client builder:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::builder()
    .referer(true)
    .build()?;
}

When enabled, each redirect sets the Referer header to the URI of the previous request.

Multipart/Form-Data

aioduct supports building multipart/form-data request bodies for file uploads and mixed form submissions.

Basic Usage

use aioduct::{TokioClient, Multipart};

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

    let form = Multipart::new()
        .text("username", "alice")
        .text("description", "Profile photo");

    let resp = client
        .post("http://example.com/upload")?
        .multipart(form)
        .send()
        .await?;

    println!("status: {}", resp.status());
    Ok(())
}

Text Fields

Add plain text form fields with .text(name, value):

#![allow(unused)]
fn main() {
use aioduct::Multipart;
let form = Multipart::new()
    .text("field1", "value1")
    .text("field2", "value2");
}

File Parts

Add file parts with .file(name, filename, content_type, data):

#![allow(unused)]
fn main() {
use aioduct::Multipart;
let form = Multipart::new()
    .text("description", "My document")
    .file("document", "report.pdf", "application/pdf", include_bytes!("../../Cargo.toml").as_slice());
}

The data parameter accepts anything that implements Into<Bytes>&[u8], Vec<u8>, String, Bytes, etc.

Mixed Forms

Combine text fields and file parts freely:

use aioduct::{TokioClient, Multipart};

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

    let image_data = std::fs::read("photo.jpg").unwrap();

    let form = Multipart::new()
        .text("title", "Vacation photo")
        .text("album", "Summer 2025")
        .file("photo", "photo.jpg", "image/jpeg", image_data);

    let resp = client
        .post("http://example.com/api/photos")?
        .multipart(form)
        .send()
        .await?;

    println!("uploaded: {}", resp.status());
    Ok(())
}

Wire Format

The generated body follows RFC 2046 multipart encoding:

------aioduct<boundary>\r\n
Content-Disposition: form-data; name="field1"\r\n
\r\n
value1\r\n
------aioduct<boundary>\r\n
Content-Disposition: form-data; name="file"; filename="photo.jpg"\r\n
Content-Type: image/jpeg\r\n
\r\n
<binary data>\r\n
------aioduct<boundary>--\r\n

The boundary is auto-generated per Multipart instance. The Content-Type header is set automatically when using .multipart() on the request builder.

Streaming Downloads

aioduct supports streaming response bodies chunk-by-chunk, avoiding the need to buffer the entire response in memory. This is essential for downloading large files.

BodyStream

Convert a response into a BodyStream that yields Bytes chunks:

use aioduct::TokioClient;

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

    let resp = client
        .get("http://example.com/large-file.bin")?
        .send()
        .await?;

    let mut stream = resp.into_bytes_stream();
    let mut total = 0usize;
    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        total += chunk.len();
        // process chunk...
    }

    println!("downloaded {total} bytes");
    Ok(())
}

Streaming to a File

Combine BodyStream with tokio::fs::File to download directly to disk:

use aioduct::TokioClient;
use tokio::io::AsyncWriteExt;

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

    let resp = client
        .get("http://example.com/large-file.bin")?
        .send()
        .await?;

    let mut file = tokio::fs::File::create("output.bin").await?;
    let mut stream = resp.into_bytes_stream();

    while let Some(chunk) = stream.next().await {
        file.write_all(&chunk?).await?;
    }
    file.flush().await?;

    Ok(())
}

Choosing Between Methods

MethodUse CaseMemory
resp.bytes()Small responses, read all at onceEntire body in memory
resp.text()Small text responsesEntire body in memory
resp.into_bytes_stream()Large downloads, progress trackingOne chunk at a time
resp.into_sse_stream()Server-Sent EventsOne event at a time

Streaming Uploads

aioduct supports streaming request bodies for large file uploads without buffering the entire content in memory. This is useful for uploading files larger than available RAM or when the content size isn’t known upfront.

RequestBody

Internally, request bodies are represented as RequestBody, which has two variants:

  • Buffered — an in-memory Bytes buffer (used by .body(), .json(), .form(), .multipart())
  • Streaming — a RequestBodySend that produces chunks on demand

Buffered bodies can be retried and redirected automatically. Streaming bodies are consumed on first use — retries and 307/308 redirects that preserve the body will send an empty body on subsequent attempts.

Basic Streaming Upload

use aioduct::{TokioClient, body::RequestBodySend};
use bytes::Bytes;
use http_body_util::{BodyExt, StreamBody};
use futures_util::stream;

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

    // Create a stream of body frames
    let chunks = vec![
        Ok(hyper::body::Frame::data(Bytes::from("chunk 1 "))),
        Ok(hyper::body::Frame::data(Bytes::from("chunk 2 "))),
        Ok(hyper::body::Frame::data(Bytes::from("chunk 3"))),
    ];
    let body: RequestBodySend = StreamBody::new(stream::iter(chunks)).boxed();

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

    println!("status: {}", resp.status());
    Ok(())
}

Streaming from a File

use aioduct::{TokioClient, body::RequestBodySend};
use bytes::Bytes;
use http_body_util::{BodyExt, StreamBody};
use tokio::io::AsyncReadExt;

#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
    let client = TokioClient::builder()
        .tls(aioduct::tls::RustlsConnector::with_webpki_roots())
        .build()?;

    let file = tokio::fs::File::open("large_file.bin").await.unwrap();
    let reader = tokio::io::BufReader::new(file);
    let stream = tokio_util::io::ReaderStream::new(reader);
    let mapped = futures_util::StreamExt::map(stream, |result| {
        result
            .map(|bytes| hyper::body::Frame::data(bytes))
            .map_err(|e| aioduct::Error::Io(e))
    });
    let body: RequestBodySend = StreamBody::new(mapped).boxed();

    let resp = client
        .put("https://httpbin.org/put")?
        .body_stream(body)
        .send()
        .await?;

    println!("status: {}", resp.status());
    Ok(())
}

Buffered vs Streaming

Feature.body() (Buffered).body_stream() (Streaming)
MemoryEntire body in RAMChunk at a time
RetryFull retry supportFirst attempt only
Redirect (307/308)Body preservedBody consumed
Redirect (301/302/303)Body dropped (GET)Body dropped (GET)

When to Use Streaming

  • Uploading files larger than available memory
  • Proxying data from one source to another
  • Generating body content dynamically (e.g., from a database cursor)

For small payloads, .body() is simpler and supports automatic retries.

Parallel Chunk Download

aioduct supports parallel chunk download for large files by splitting the download into multiple HTTP Range requests fetched concurrently. This can significantly improve download speed when the server supports range requests.

Basic Usage

use aioduct::TokioClient;

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

    let result = client
        .chunk_download("http://example.com/large-file.bin")
        .chunks(8)
        .download()
        .await?;

    println!("Downloaded {} bytes", result.total_size);
    // result.data contains the reassembled file
    Ok(())
}

How It Works

  1. HEAD request — checks Accept-Ranges: bytes and Content-Length headers
  2. Range splitting — divides the file into N equal-sized chunks
  3. Parallel fetch — spawns concurrent Range requests via the runtime
  4. Reassembly — collects chunks in order and concatenates them

If the server doesn’t support range requests (no Accept-Ranges: bytes header or missing Content-Length), the download falls back to a single GET request.

Configuration

MethodDefaultDescription
.chunks(n)4Number of parallel range requests

Result

ChunkDownloadResult contains:

  • total_size: u64 — the total file size in bytes
  • data: Bytes — the complete downloaded content

Server Requirements

For parallel download to activate, the server must:

  • Respond to HEAD with Accept-Ranges: bytes
  • Include a Content-Length header
  • Support Range: bytes=start-end requests and respond with 206 Partial Content

Example: Download and Save to File

use aioduct::TokioClient;
use tokio::io::AsyncWriteExt;

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

    let result = client
        .chunk_download("http://example.com/large-file.zip")
        .chunks(8)
        .download()
        .await?;

    let mut file = tokio::fs::File::create("large-file.zip").await?;
    file.write_all(&result.data).await?;

    println!("Downloaded {} bytes", result.total_size);
    Ok(())
}

Notes

  • The client is cloned (cheaply — all internal state is behind Arc) for each parallel task
  • If any chunk request fails, the entire download fails
  • The number of chunks is capped at the total file size (1-byte minimum per chunk)

HTTP/3

aioduct has experimental HTTP/3 support via h3, h3-quinn, and quinn.

Feature Flag

Enable the http3 transport feature with the rustls backend and a rustls crypto provider:

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "http3", "rustls", "rustls-ring"] }

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

[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "http3", "rustls", "rustls-aws-lc-rs"] }

The http3 feature only selects the QUIC/HTTP/3 transport dependencies. Today HTTP/3 still requires the rustls backend because quinn uses rustls for QUIC TLS; choose exactly one of rustls-ring or rustls-aws-lc-rs.

Usage

There are two modes for HTTP/3:

Always-H3 Mode

Force all HTTPS requests through QUIC/HTTP/3:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

// All HTTPS requests will use HTTP/3
let client = TokioClient::with_http3()?;
}

Or via the builder:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;

let client = TokioClient::builder()
    .tls(RustlsConnector::with_webpki_roots())
    .http3(true)?
    .build()?;
}

Alt-Svc Auto-Upgrade Mode

Start with HTTP/1.1 or HTTP/2 over TCP, and automatically upgrade to HTTP/3 when the server advertises it via the Alt-Svc header:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

// First request uses TCP; upgrades to QUIC when Alt-Svc is seen
let client = TokioClient::with_alt_svc_h3()?;
}

Or via the builder:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;

let client = TokioClient::builder()
    .tls(RustlsConnector::with_webpki_roots())
    .alt_svc_h3(true)
    .build()?;
}

Important: .tls() must be called before .http3(true) or .alt_svc_h3(true) when you provide a custom TLS connector because HTTP/3 reuses that rustls configuration to build the QUIC endpoint.

Alt-Svc Protocol Upgrade

When Alt-Svc auto-upgrade is enabled (.alt_svc_h3(true) or with_alt_svc_h3()):

  1. The first request to a new origin goes over TCP (HTTP/1.1 or HTTP/2 via ALPN).
  2. If the response includes an Alt-Svc header advertising h3 (e.g., Alt-Svc: h3=":443"; ma=86400), the client caches this.
  3. Subsequent requests to the same origin use QUIC/HTTP/3 instead of TCP.
  4. The cache respects ma (max-age) — entries expire after the specified duration (default 24 hours).
  5. Alt-Svc: clear removes cached entries, reverting to TCP for that origin.

The Alt-Svc cache supports alternate hosts and ports. For example, h3="alt.example.com:8443" routes QUIC traffic to a different endpoint while keeping the original host for SNI.

How It Works

When HTTP/3 is enabled (either mode):

  1. HTTPS requests are sent over QUIC using the quinn transport. The client opens a QUIC connection, performs the TLS 1.3 handshake, and sends the request via the h3 protocol.
  2. HTTP requests (plain) continue to use TCP-based HTTP/1.1 or HTTP/2 as usual.
  3. Connection pooling works for QUIC connections the same way it does for TCP — the pool checks for an existing idle connection to the same (scheme, authority) before establishing a new one. Like HTTP/2, HTTP/3 multiplexes streams over a single connection.

When HTTP/3 is not enabled (default), the client uses TCP with HTTP/1.1 or HTTP/2 negotiated via ALPN, even for HTTPS.

0-RTT (Early Data)

For repeat connections to servers that support session tickets, aioduct can send the first request immediately without waiting for a full QUIC handshake:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::builder()
    .tls(aioduct::tls::RustlsConnector::with_webpki_roots())
    .http3(true)?
    .h3_zero_rtt(true)
    .build()?;
}

Safety

0-RTT is only used for idempotent methods (GET, HEAD, OPTIONS) because early data can be replayed by an attacker. POST, PUT, DELETE, and PATCH always wait for the full handshake.

If the server rejects 0-RTT, the client transparently falls back to a full handshake with no user intervention required.

When to Enable

  • Enable for latency-sensitive read-heavy workloads (API polling, CDN fetches) where the server is known to support session tickets.
  • Leave disabled (default) if your application has strict non-replay requirements or the server doesn’t benefit from it.

Limitations

  • Experimental — the h3 ecosystem (h3 0.0.8, h3-quinn 0.0.10) is pre-1.0.
  • No fallback — in always-h3 mode, if the server doesn’t support QUIC, the request fails rather than falling back to TCP. Use Alt-Svc mode or the default (non-h3) client for servers where QUIC support is uncertain.
  • Tokio only — quinn requires tokio, so HTTP/3 is only available with the tokio runtime feature.
  • rustls required today — future TLS backend work may change the available combinations, but current HTTP/3 support composes with rustls provider features.

Cookie Jar

aioduct supports automatic cookie management through a CookieJar. When enabled, cookies from Set-Cookie response headers are stored and automatically sent in subsequent requests to the same domain.

Enabling Cookies

Create a CookieJar and pass it to the client builder:

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, CookieJar};

let jar = CookieJar::new();
let client = TokioClient::builder()
    .cookie_jar(jar)
    .build()?;
}

How It Works

  1. When a response contains Set-Cookie headers, the jar stores each cookie keyed by domain
  2. On subsequent requests, matching cookies are sent in the Cookie header
  3. Cookies with the Secure flag are only sent over HTTPS
  4. If a response sets a cookie with the same name, it replaces the existing one
  5. Cookies with Max-Age=0 or a past Expires date are removed from the jar
  6. The Path attribute is respected — cookies are only sent for matching request paths
  7. Domain matching supports subdomains — a cookie for example.com is sent to sub.example.com

Example: Session-Based API

use aioduct::{TokioClient, CookieJar};

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

    // Login — server sets session cookie
    client
        .post("http://example.com/login")?
        .form(&[("user", "alice"), ("pass", "secret")])
        .send()
        .await?;

    // Subsequent requests automatically include the session cookie
    let resp = client
        .get("http://example.com/dashboard")?
        .send()
        .await?;

    println!("{}", resp.text().await?);
    Ok(())
}

Clearing Cookies

#![allow(unused)]
fn main() {
use aioduct::CookieJar;
let jar = CookieJar::new();
// ... use jar with client ...
jar.clear(); // remove all stored cookies
}

By default, no cookie jar is configured. Responses with Set-Cookie headers are ignored, and no Cookie header is sent automatically. You can still manage cookies manually via header_str("cookie", "...").

Inspecting Cookies

The CookieJar and Cookie types are public, allowing inspection of stored cookies:

#![allow(unused)]
fn main() {
use aioduct::CookieJar;
let jar = CookieJar::new();
// ... use jar with client ...

for cookie in jar.cookies() {
    println!("{} = {}", cookie.name(), cookie.value());
    if let Some(domain) = cookie.domain() {
        println!("  domain: {domain}");
    }
    if let Some(path) = cookie.path() {
        println!("  path: {path}");
    }
    println!("  secure: {}", cookie.secure());
    println!("  http_only: {}", cookie.http_only());
}
}
MethodReturn TypeDescription
name()&strCookie name
value()&strCookie value
domain()Option<&str>Domain attribute (defaults to request domain)
path()Option<&str>Path attribute
secure()boolWhether the cookie requires HTTPS
http_only()boolWhether the cookie is HTTP-only
same_site()Option<&SameSite>SameSite attribute (Strict, Lax, or None)

SameSite Cookies

aioduct parses the SameSite attribute from Set-Cookie headers per the RFC 6265bis draft:

  • Strict — cookie is only sent in first-party context (same-site requests)
  • Lax — cookie is sent on top-level navigations and same-site requests (browser default)
  • None — cookie is sent in all contexts (requires Secure flag)
#![allow(unused)]
fn main() {
use aioduct::cookie::SameSite;
use aioduct::CookieJar;
let jar = CookieJar::new();
// ... use jar with client ...
for cookie in jar.cookies() {
    match cookie.same_site() {
        Some(SameSite::Strict) => println!("{}: strict", cookie.name()),
        Some(SameSite::Lax) => println!("{}: lax", cookie.name()),
        Some(SameSite::None) => println!("{}: none", cookie.name()),
        None => println!("{}: not set", cookie.name()),
    }
}
}

aioduct enforces cookie prefix validation per RFC 6265bis:

  • __Host- — requires Secure, exact domain match (no Domain attribute pointing elsewhere), and Path=/
  • __Secure- — requires Secure flag

Cookies that fail prefix validation are silently rejected.

Domain Matching

Cookies use RFC-compliant domain matching with subdomain support:

  • A cookie stored for example.com matches requests to example.com and sub.example.com
  • A cookie stored for sub.example.com does not match example.com or other.example.com
  • Leading dots in the Domain attribute are stripped (Domain=.example.com becomes example.com)

Path Scoping

When a Set-Cookie header includes a Path attribute, the cookie is only sent for requests whose path starts with the cookie’s path:

Set-Cookie: token=abc; Path=/api
  • /api — cookie sent
  • /api/users — cookie sent
  • / — cookie not sent
  • /other — cookie not sent

Expiration

Cookies are expired and removed from the jar when:

  • Max-Age=0 or a negative value is received
  • An Expires date in the past is received (RFC 7231 date format: Wed, 21 Oct 2015 07:28:00 GMT)

Expired cookies are never stored; setting Max-Age=0 on an existing cookie removes it.

HTTP Caching

aioduct includes an in-memory HTTP cache that respects Cache-Control directives, conditional validation with ETag/If-None-Match and Last-Modified/If-Modified-Since, and stale content extensions.

Enabling the Cache

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, HttpCache};

let cache = HttpCache::new();
let client = TokioClient::builder()
    .cache(cache)
    .build()?;
}

Cache-Control Directives

The cache respects standard directives from RFC 9111:

DirectiveBehavior
max-age=NResponse is fresh for N seconds
s-maxage=NShared cache max-age (takes precedence over max-age)
no-cacheAlways revalidate before serving
no-storeNever store the response
must-revalidateMust revalidate once stale
privateResponse is not cacheable by shared caches

Immutable Responses (RFC 8246)

Responses with Cache-Control: immutable are never revalidated while fresh. This is useful for content-addressed resources (e.g., /assets/app-abc123.js) that never change at the same URL.

Cache-Control: max-age=31536000, immutable

The cache skips conditional requests entirely for these entries.

Stale Content Extensions (RFC 5861)

stale-while-revalidate

Allows the cache to serve a stale response while asynchronously revalidating in the background:

Cache-Control: max-age=60, stale-while-revalidate=30

The response is served fresh for 60 seconds, then served stale for up to 30 more seconds while a background revalidation occurs.

stale-if-error

Allows the cache to serve a stale response when the origin server returns a 5xx error or is unreachable:

Cache-Control: max-age=60, stale-if-error=3600

If the origin is unavailable, the stale response can be served for up to 3600 seconds past expiry.

Client behavior: When the client holds a stale cached response with a stale-if-error directive, it first attempts a normal request to the origin (including conditional validation headers). If the origin returns a 5xx status code or the connection fails entirely, the client checks whether the cached entry’s age is within the stale-if-error grace window. If so, the stale cached response is returned transparently instead of the error. If the grace window has expired, the original error is propagated.

Conditional Validation

When a cached response becomes stale, the cache performs conditional validation:

  1. If the cached response has an ETag, the request includes If-None-Match
  2. If the cached response has a Last-Modified date, the request includes If-Modified-Since
  3. A 304 Not Modified response refreshes the cache entry without transferring the body

Cache Configuration

CacheConfig controls cache behavior:

#![allow(unused)]
fn main() {
use aioduct::{CacheConfig, HttpCache};

let cache = HttpCache::with_config(CacheConfig::default());
}
MethodDefaultDescription
max_entries()256Maximum number of cached responses

What Gets Cached

Only responses to safe, idempotent methods (GET, HEAD) with cacheable status codes (200, 301, etc.) are cached. Unsafe methods (POST, PUT, DELETE, PATCH) invalidate matching cache entries.

Shared State

HttpCache uses Arc internally, so cloning shares state between clients:

#![allow(unused)]
fn main() {
use aioduct::HttpCache;
let cache = HttpCache::new();
let cache2 = cache.clone(); // shares the same data
}

Custom Cache Store

Implement the CacheStore trait to plug in a custom backend (moka, foyer, Redis, etc.):

#![allow(unused)]
fn main() {
use aioduct::{CacheStore, CacheEntry, HttpCache, TokioClient};
use http::{Method, Uri};

struct MyCacheStore { /* ... */ }

impl CacheStore for MyCacheStore {
    fn get(&self, method: &Method, uri: &Uri) -> Option<CacheEntry> {
        // look up entry
        None
    }
    fn put(&self, method: &Method, uri: &Uri, entry: CacheEntry) {
        // store entry
    }
    fn remove(&self, method: &Method, uri: &Uri) {
        // remove entry
    }
    fn clear(&self) {
        // clear all entries
    }
    fn len(&self) -> usize {
        // return count
        0
    }
}

let cache = HttpCache::with_store(MyCacheStore { /* ... */ });
let client = TokioClient::builder()
    .cache(cache)
    .build()?;
}

The built-in InMemoryCacheStore is available if you need a reference implementation or want to wrap it with additional behavior (metrics, logging, etc.).

HSTS (HTTP Strict Transport Security)

aioduct supports automatic HTTP-to-HTTPS upgrade via the Strict-Transport-Security header (RFC 6797). When a server sends this header over HTTPS, subsequent HTTP requests to that domain are transparently upgraded to HTTPS.

Enabling HSTS

Create an HstsStore and pass it to the client builder:

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, HstsStore};

let hsts = HstsStore::new();
let client = TokioClient::builder()
    .tls(aioduct::tls::RustlsConnector::with_webpki_roots())
    .hsts(hsts)
    .build()?;
}

How It Works

  1. When an HTTPS response contains a Strict-Transport-Security header, the domain and its policy are recorded in the store
  2. On subsequent requests to the same domain over http://, the URL is transparently upgraded to https://
  3. If the header includes includeSubDomains, all subdomains of the host are also upgraded
  4. A max-age=0 directive removes the domain from the store

Header Format

Strict-Transport-Security: max-age=31536000
Strict-Transport-Security: max-age=31536000; includeSubDomains
  • max-age — how long (in seconds) the browser/client should remember to use HTTPS
  • includeSubDomains — also apply the policy to all subdomains

Subdomain Matching

When includeSubDomains is set for example.com:

  • http://example.com → upgraded to https://example.com
  • http://api.example.com → upgraded to https://api.example.com
  • http://deep.sub.example.com → upgraded to https://deep.sub.example.com

Without includeSubDomains, only the exact domain is upgraded.

Shared State

HstsStore uses Arc<Mutex<...>> internally, so cloning a store shares state between clients:

#![allow(unused)]
fn main() {
use aioduct::HstsStore;
let store = HstsStore::new();
let store2 = store.clone(); // shares the same data
}

Clearing the Store

#![allow(unused)]
fn main() {
use aioduct::HstsStore;
let store = HstsStore::new();
// ... use with client ...
store.clear();
}

Response Decompression

aioduct can automatically decompress response bodies based on the Content-Encoding header. Each compression algorithm is gated behind its own feature flag.

Feature Flags

FeatureCodecCrate
gzipgzipflate2
deflatedeflateflate2
brotlibrbrotli
zstdzstdzstd
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "gzip", "brotli"] }

How It Works

When any decompression feature is enabled:

  1. The client adds an Accept-Encoding header to outgoing requests listing the enabled codecs (unless you already set one).
  2. If the response has a matching Content-Encoding, the body is transparently decompressed.
  3. The Content-Encoding and Content-Length headers are removed from the decompressed response.
use aioduct::TokioClient;

#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
    // With the `gzip` feature enabled, gzip responses are decompressed automatically
    let client = TokioClient::with_rustls();

    let text = client.get("https://httpbin.org/gzip")?
        .send().await?
        .text().await?;
    println!("{text}");
    Ok(())
}

Disabling Decompression

Use no_decompression() on the builder to disable all automatic decompression. The raw compressed bytes are returned as-is.

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::builder()
    .no_decompression()
    .build()?;
}

Supported Encodings

The Accept-Encoding header is built from the enabled features. For example, with gzip and brotli enabled, outgoing requests include:

Accept-Encoding: zstd, gzip, deflate, br

Only codecs whose feature flag is compiled in will appear. If you set Accept-Encoding manually on a request, the client will not overwrite it.

Proxy Support

aioduct supports routing requests through HTTP and SOCKS5 proxies. For HTTP targets via an HTTP proxy, the request is sent directly to the proxy. For HTTPS targets, a CONNECT tunnel is established. SOCKS5 proxies tunnel all traffic regardless of scheme.

Basic Usage

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig};

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

// SOCKS5 proxy
let client = TokioClient::builder()
    .proxy(ProxyConfig::socks5("socks5://socks-proxy.example.com:1080").unwrap())
    .build()?;
}

System Proxy (Environment Variables)

Use system_proxy() to read proxy settings from environment variables:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::builder()
    .system_proxy()
    .build()?;
}

This reads:

  • HTTP_PROXY / http_proxy — proxy for HTTP requests
  • HTTPS_PROXY / https_proxy — proxy for HTTPS requests
  • NO_PROXY / no_proxy — comma-separated list of hosts to bypass

The uppercase variant takes precedence over the lowercase variant.

NO_PROXY Rules

The NO_PROXY value is a comma-separated list of patterns:

PatternMatches
example.comexample.com and *.example.com
.example.com*.example.com (subdomains only)
*All hosts (disables proxy)
127.0.0.1Exact IP match

Advanced: Separate HTTP/HTTPS Proxies

Use ProxySettings for fine-grained control:

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig, ProxySettings, NoProxy};

let settings = ProxySettings::all(
    ProxyConfig::http("http://proxy.example.com:8080").unwrap()
)
.no_proxy(NoProxy::new("localhost, .internal.corp, 10.0.0.0/8"));

let client = TokioClient::builder()
    .proxy_settings(settings)
    .build()?;
}

You can also set different proxies for HTTP and HTTPS:

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig, ProxySettings, NoProxy};
let settings = ProxySettings::default()
    .http(ProxyConfig::http("http://http-proxy:3128").unwrap())
    .https(ProxyConfig::http("http://https-proxy:3129").unwrap())
    .no_proxy(NoProxy::new("localhost"));

let client = TokioClient::builder()
    .proxy_settings(settings)
    .build()?;
}

Proxy Authentication

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig};

let client = TokioClient::builder()
    .proxy(
        ProxyConfig::http("http://proxy.example.com:8080")
            .unwrap()
            .basic_auth("user", "pass"),
    )
    .build()?;
}

How It Works

HTTP Targets

For plain HTTP requests, the client connects to the proxy and sends the request with the full absolute URI in the request line. The proxy forwards the request to the target server.

HTTPS Targets (CONNECT Tunnel)

For HTTPS requests, the client:

  1. Connects to the proxy via TCP
  2. Sends CONNECT host:port HTTP/1.1 to establish a tunnel
  3. Waits for a 200 response from the proxy
  4. Performs TLS handshake through the tunnel
  5. Sends the actual HTTPS request over the encrypted connection

This ensures end-to-end encryption — the proxy only sees the target hostname, not the request content.

Example: Corporate Proxy

use aioduct::{TokioClient, ProxyConfig};

#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
    let client = TokioClient::builder()
        .proxy(
            ProxyConfig::http("http://corporate-proxy:3128")
                .unwrap()
                .basic_auth("employee", "password"),
        )
        .tls(aioduct::tls::RustlsConnector::with_webpki_roots())
        .build()?;

    let resp = client
        .get("https://api.example.com/data")?
        .send()
        .await?;

    println!("{}", resp.text().await?);
    Ok(())
}

SOCKS4/SOCKS4a Proxy

SOCKS4/SOCKS4a proxies are also supported. SOCKS4a extends SOCKS4 with domain name resolution on the proxy side:

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig};

// SOCKS4a proxy (domain resolution on proxy)
let client = TokioClient::builder()
    .proxy(ProxyConfig::socks4("socks4a://localhost:1080").unwrap())
    .build()?;

// SOCKS4 proxy
let client = TokioClient::builder()
    .proxy(ProxyConfig::socks4("socks4://localhost:1080").unwrap())
    .build()?;
}

SOCKS4 supports optional user ID authentication (passed via basic_auth — only the username is used).

Environment variables with socks4:// or socks4a:// URLs are automatically detected by system_proxy().

SOCKS5 Proxy

SOCKS5 proxies tunnel TCP connections at a lower level than HTTP proxies. After the SOCKS5 handshake, the TCP stream is used directly — for HTTP targets, the client sends a normal request; for HTTPS targets, TLS is negotiated over the tunnel.

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig};

// Without auth
let client = TokioClient::builder()
    .proxy(ProxyConfig::socks5("socks5://localhost:1080").unwrap())
    .build()?;

// With username/password auth
let client = TokioClient::builder()
    .proxy(
        ProxyConfig::socks5("socks5://localhost:1080")
            .unwrap()
            .basic_auth("user", "pass"),
    )
    .build()?;
}

Environment variables with socks5:// URLs are automatically detected by system_proxy().

Limitations

  • SOCKS5 supports no-auth and username/password authentication (RFC 1928/1929)
  • SOCKS4 supports optional user ID authentication
  • The HTTP proxy URI must use http:// scheme; the SOCKS5 proxy URI must use socks5://; the SOCKS4 proxy URI must use socks4:// or socks4a://

HTTP/2 Tuning

aioduct automatically negotiates HTTP/2 when the server supports it via ALPN during TLS. You can fine-tune HTTP/2 connection parameters using Http2Config.

Usage

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, Http2Config};
use std::time::Duration;

let client = TokioClient::builder()
    .tls(aioduct::tls::RustlsConnector::with_webpki_roots())
    .http2(
        Http2Config::new()
            .initial_stream_window_size(2 * 1024 * 1024)
            .initial_connection_window_size(4 * 1024 * 1024)
            .max_frame_size(32_768)
            .adaptive_window(true)
            .keep_alive_interval(Duration::from_secs(20))
            .keep_alive_timeout(Duration::from_secs(10))
            .keep_alive_while_idle(true),
    )
    .build()?;
}

Available Options

MethodDescription
initial_stream_window_size(u32)Per-stream flow control window (bytes)
initial_connection_window_size(u32)Connection-level flow control window (bytes)
max_frame_size(u32)Max HTTP/2 frame payload (16,384–16,777,215)
adaptive_window(bool)Auto-tune window sizes based on BDP estimates
keep_alive_interval(Duration)Send PING frames at this interval
keep_alive_timeout(Duration)Close connection if PING not ACK’d within this time
keep_alive_while_idle(bool)Send PINGs even when no active streams
max_header_list_size(u32)Max size of received header list (bytes)
max_send_buf_size(usize)Max write buffer size per stream (bytes)
max_concurrent_reset_streams(usize)Max locally-reset streams tracked

Flow Control Window Sizing

HTTP/2 uses flow control to prevent a fast sender from overwhelming a slow receiver. The default window sizes (65,535 bytes) are conservative. For high-bandwidth or high-latency connections, larger windows improve throughput:

#![allow(unused)]
fn main() {
use aioduct::Http2Config;
let config = Http2Config::new()
    .initial_stream_window_size(1024 * 1024)       // 1 MB per stream
    .initial_connection_window_size(2 * 1024 * 1024) // 2 MB total
    .adaptive_window(true);                         // auto-tune
}

Keep-Alive

HTTP/2 PING frames detect dead connections before the OS does. This is especially useful for long-lived connections behind load balancers:

#![allow(unused)]
fn main() {
use aioduct::Http2Config;
use std::time::Duration;
let config = Http2Config::new()
    .keep_alive_interval(Duration::from_secs(30))
    .keep_alive_timeout(Duration::from_secs(10))
    .keep_alive_while_idle(true);
}

When to Use

  • Default (no config): Fine for most use cases
  • Large downloads/uploads: Increase window sizes
  • High-latency links: Enable adaptive window
  • Long-lived connections: Enable keep-alive PINGs
  • Behind aggressive LBs/proxies: Short keep-alive intervals

Middleware

aioduct supports a middleware layer that lets you intercept and modify requests before they are sent and responses after they are received. This is useful for cross-cutting concerns like logging, metrics, authentication token refresh, or header injection.

The Middleware Trait

#![allow(unused)]
fn main() {
pub trait Middleware: Send + Sync + 'static {
    fn on_request(&self, request: &mut http::Request<RequestBodySend>, uri: &Uri) { }
    fn on_response(&self, response: &mut http::Response<RequestBodySend>, uri: &Uri) { }
}
}

Both methods have default no-op implementations, so you only need to override what you use.

Using Closures

For simple request-only middleware, you can pass a closure directly:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::builder()
    .middleware(|req: &mut http::Request<aioduct::RequestBodySend>, _uri: &http::Uri| {
        req.headers_mut().insert(
            http::header::HeaderName::from_static("x-custom"),
            http::header::HeaderValue::from_static("value"),
        );
    })
    .build()?;
}

Using a Struct

For middleware that needs to modify responses or maintain state, implement the trait on a struct:

#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use aioduct::{TokioClient, RequestBodySend, Middleware};

struct RequestCounter {
    count: Arc<AtomicU64>,
}

impl Middleware for RequestCounter {
    fn on_request(&self, _req: &mut http::Request<RequestBodySend>, _uri: &http::Uri) {
        self.count.fetch_add(1, Ordering::Relaxed);
    }
}

let counter = Arc::new(AtomicU64::new(0));
let client = TokioClient::builder()
    .middleware(RequestCounter { count: counter.clone() })
    .build()?;
}

Stacking Multiple Middleware

You can add multiple middleware layers. They execute in order:

  • Request hooks run first-to-last (in the order they were added).
  • Response hooks run last-to-first (reverse order).
#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::builder()
    .middleware(|req: &mut http::Request<aioduct::RequestBodySend>, _uri: &http::Uri| {
        // Runs first on request
        req.headers_mut().insert(
            http::header::HeaderName::from_static("x-trace-id"),
            http::header::HeaderValue::from_static("abc123"),
        );
    })
    .middleware(|req: &mut http::Request<aioduct::RequestBodySend>, _uri: &http::Uri| {
        // Runs second on request
        req.headers_mut().insert(
            http::header::HeaderName::from_static("x-auth"),
            http::header::HeaderValue::from_static("Bearer tok"),
        );
    })
    .build()?;
}

When Middleware Runs

Middleware hooks run at these points in the request lifecycle:

  1. The request is fully built (headers, body, query params applied).
  2. on_request is called for each middleware in order.
  3. The request is sent over the connection.
  4. The response is received.
  5. on_response is called for each middleware in reverse order.
  6. Decompression is applied (if enabled).
  7. The response is returned to the caller.

Note that middleware runs on each individual request, including redirect hops.

Digest Authentication

aioduct supports HTTP Digest Authentication (RFC 7616). When configured, the client automatically handles the 401 challenge-response flow — no manual header construction needed.

How It Works

  1. The client sends the initial request without credentials.
  2. If the server responds with 401 Unauthorized and a WWW-Authenticate: Digest ... header, the client parses the challenge.
  3. The client computes the digest response using the MD5 algorithm, the request method, URI, and the server-provided nonce.
  4. The request is retried with the Authorization: Digest ... header.

This is a single automatic retry — if the second request also returns 401, it is returned as-is.

Usage

Configure digest auth at the client level:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::builder()
    .digest_auth("username", "password")
    .build()?;

// The client handles the 401 → retry flow automatically
let resp = client
    .get("https://example.com/protected")?
    .send()
    .await?;
}

Supported Features

FeatureStatus
MD5 algorithmSupported
qop=authSupported
opaque parameterSupported
Nonce counting (nc)Supported
Client nonce (cnonce)Supported
MD5-sessNot supported
SHA-256Not supported
qop=auth-intNot supported

Implementation Notes

  • The MD5 implementation is pure Rust with no external dependency.
  • The nonce counter is atomic, so digest auth is safe to use from concurrent requests.
  • Digest auth runs after the initial request completes but before redirect handling, so it works correctly with redirect-protected resources.
  • The client nonce is generated using RandomState for uniqueness without requiring a CSPRNG dependency.

Bandwidth Limiting

aioduct provides a token-bucket bandwidth limiter for throttling download speed. Unlike the RateLimiter which limits requests per second, the bandwidth limiter limits bytes per second.

Usage

Set a maximum download speed at the client level:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

let client = TokioClient::builder()
    .max_download_speed(1_048_576) // 1 MB/s
    .build()?;

let resp = client
    .get("https://example.com/large-file.tar.gz")?
    .send()
    .await?;
}

How It Works

When max_download_speed is set on the client, aioduct automatically wraps every response body in a BandwidthBody that gates data frames through the token bucket:

  1. The bucket starts full — capacity equals bytes_per_sec.
  2. When the response body is read, each data frame is checked against the bucket.
  3. If enough tokens are available, the frame is emitted immediately and tokens are consumed.
  4. If the bucket is empty, the frame is buffered and the read yields until tokens refill (the executor re-polls; tokens refill continuously based on wall-clock elapsed time).
  5. Non-data frames (trailers) pass through without consuming tokens.

API

The BandwidthLimiter type is also available standalone for manual use cases (e.g., upload throttling):

#![allow(unused)]
fn main() {
use aioduct::BandwidthLimiter;
use std::time::Duration;

let limiter = BandwidthLimiter::new(100_000); // 100 KB/s

// Try to consume bytes (non-blocking)
let granted = limiter.try_consume(8192);

// Check how long to wait for more bytes
let wait = limiter.wait_duration(8192);
}
MethodDescription
try_consume(n)Consume up to n bytes, returns bytes actually granted (may be 0)
wait_duration(n)Duration to wait before n bytes become available

Shared State

BandwidthLimiter uses Arc internally, so cloning shares the same token bucket. This means the limit is enforced globally across all concurrent requests on the same client.

Netrc Support

aioduct can read .netrc files and automatically inject credentials into requests. This follows the same convention used by curl, wget, and other HTTP tools.

What is .netrc?

A .netrc file maps hostnames to login credentials:

machine api.example.com
  login myuser
  password mytoken

machine registry.npmjs.org
  login npm_user
  password npm_pass

default
  login anonymous
  password guest

The file is typically located at ~/.netrc (or %USERPROFILE%\_netrc on Windows). The $NETRC environment variable overrides the default path.

Using NetrcMiddleware

The simplest approach is to add NetrcMiddleware to your client. It reads the netrc file once and injects Basic Auth headers for matching hosts:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::NetrcMiddleware;

let client = TokioClient::builder()
    .middleware(NetrcMiddleware::from_default().unwrap())
    .build()?;

// Requests to api.example.com automatically get Basic Auth
let resp = client
    .get("https://api.example.com/data")?
    .send()
    .await?;
}

Loading from a Specific Path

#![allow(unused)]
fn main() {
use std::path::Path;
use aioduct::NetrcMiddleware;

let middleware = NetrcMiddleware::from_path(Path::new("/etc/netrc")).unwrap();
}

Parsing Directly

You can also use the Netrc type directly for credential lookup without middleware:

#![allow(unused)]
fn main() {
use aioduct::Netrc;

let netrc = Netrc::parse(
    "machine example.com login user1 password pass1\n\
     default login anon password anon\n"
);
}

Behavior

  • If a request already has an Authorization header, the middleware does not overwrite it.
  • Machine names are matched exactly against the request URI’s host.
  • The default entry matches any host not explicitly listed.
  • Both password and passwd keywords are accepted.
  • The account and macdef keywords are recognized and skipped.

HTTP Upgrade (WebSocket)

aioduct supports HTTP/1.1 protocol upgrades (101 Switching Protocols) and HTTP/2 extended CONNECT (RFC 8441), commonly used for WebSocket connections. After a successful upgrade handshake, you get a bidirectional IO stream.

Basic Usage (HTTP/1.1)

#![allow(unused)]
fn main() {
use aioduct::TokioClient;

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

let resp = client
    .get("http://example.com/ws")?
    .upgrade()  // sets Connection: Upgrade + Upgrade: websocket + HTTP/1.1
    .send()
    .await?;

assert_eq!(resp.status(), http::StatusCode::SWITCHING_PROTOCOLS);

let upgraded = resp.upgrade().await?;
// `upgraded` implements hyper's Read + Write traits
// With the `tokio` feature, it also implements tokio::io::AsyncRead + AsyncWrite
Ok(())
}
}

HTTP/2 Extended CONNECT (RFC 8441)

For HTTP/2 upstreams that support the extended CONNECT protocol, use Protocol to signal the desired sub-protocol:

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, Protocol};

async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::builder()
    .http2_prior_knowledge()
    .build()?;

let mut req = client
    .get("http://example.com/ws/chat")?
    .build();
*req.method_mut() = http::Method::CONNECT;
req.extensions_mut().insert(Protocol::from_static("websocket"));

let resp = client.execute(req).await?;
assert_eq!(resp.status(), http::StatusCode::OK);

let upgraded = resp.upgrade().await?;
// Bidirectional tunnel over the H2 stream
Ok(())
}
}

For proxy/gateway use cases, see Request Forwarding which auto-detects both upgrade mechanisms.

How It Works

  1. HTTP/1.1: Call .upgrade() on the RequestBuilder to set the required headers (Connection: Upgrade, Upgrade: websocket) and force HTTP/1.1.
  2. HTTP/2: Insert a Protocol extension into the request and use CONNECT method. The server must have SETTINGS_ENABLE_CONNECT_PROTOCOL enabled.
  3. Send the request and check for 101 (H1) or 200 (H2 CONNECT).
  4. Call .upgrade() on the Response to consume it and obtain an Upgraded stream.
  5. The connection is not returned to the pool — it’s exclusively yours.

The Upgraded Type

Upgraded is a bidirectional IO stream:

  • Implements hyper::rt::Read and hyper::rt::Write (always available)
  • Implements tokio::io::AsyncRead and tokio::io::AsyncWrite (when the tokio feature is enabled)
  • Can be converted to the underlying hyper::upgrade::Upgraded via .into_inner()
  • Can be constructed from hyper::upgrade::Upgraded via Upgraded::from()

Using with WebSocket Libraries

Pass the Upgraded stream to your WebSocket library of choice. For example, with tokio-tungstenite:

let upgraded = resp.upgrade().await?;
let ws_stream = tokio_tungstenite::WebSocketStream::from_raw_socket(
    upgraded,
    tokio_tungstenite::tungstenite::protocol::Role::Client,
    None,
).await;

Notes

  • HTTP/1.1 upgrades use Connection: Upgrade + Upgrade: websocket headers → 101
  • HTTP/2 extended CONNECT uses CONNECT method + :protocol pseudo-header → 200
  • After upgrade, the connection/stream is consumed — it won’t be returned to the pool
  • You can set additional WebSocket-specific headers (like Sec-WebSocket-Key) manually via .header_str()

Request Forwarding

aioduct includes a built-in request forwarding builder for reverse proxy and API gateway use cases. It strips hop-by-hop headers, rewrites the URI to target an upstream, streams the body without buffering, and bypasses all client middleware (redirects, cookies, cache, decompression).

Basic Forwarding

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;

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

// Incoming request from your framework (axum, actix, hyper, etc.)
let incoming_req = http::Request::builder()
    .method("GET")
    .uri("/api/users?page=2")
    .header("host", "public-gateway.example.com")
    .body(Full::new(Bytes::new()))
    .unwrap();

let resp = client
    .forward(incoming_req)
    .upstream("http://backend:8080".parse::<http::Uri>().unwrap())
    .strip_prefix("/api")       // /api/users → /users
    .send()
    .await?;

println!("status: {}", resp.status());
Ok(())
}
}

Builder Methods

MethodDescription
.upstream(uri)Target upstream origin (required)
.strip_prefix(prefix)Remove a path prefix before forwarding
.preserve_host()Keep the original Host header instead of rewriting to upstream
.timeout(duration)Per-request timeout
.header(name, value)Inject an extra header
.forward_header(name)Copy a named header through hop-by-hop stripping
.remove_header(name)Remove a header before sending
.on_request(fn)Mutate request parts just before sending
.on_response(fn)Mutate the response before returning
.h2c()Force HTTP/2 prior knowledge (h2c) on this forward
.adaptive_h2c()Probe h2c, fall back to h1; result cached per-authority
.upgrade()Force upgrade header preservation (usually auto-detected)

Hop-by-Hop Header Stripping

ForwardBuilder automatically strips these headers from both the incoming request and the upstream response:

  • Connection
  • Keep-Alive
  • Proxy-Authenticate
  • Proxy-Authorization
  • Proxy-Connection
  • TE
  • Trailer
  • Transfer-Encoding

Use .forward_header(name) to preserve specific headers through stripping.

WebSocket / HTTP Upgrade Forwarding

Upgrade requests are auto-detected and handled correctly:

HTTP/1.1 Upgrade

When Connection: Upgrade is present, ForwardBuilder:

  • Preserves Connection and Upgrade headers through hop-by-hop stripping
  • Forces HTTP/1.1 on the upstream connection
  • Skips response hop-by-hop stripping (101 is terminal)
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;

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

let ws_req = http::Request::builder()
    .method("GET")
    .uri("/ws/chat")
    .header("connection", "Upgrade")
    .header("upgrade", "websocket")
    .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
    .header("sec-websocket-version", "13")
    .body(Full::new(Bytes::new()))
    .unwrap();

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

assert_eq!(resp.status(), http::StatusCode::SWITCHING_PROTOCOLS);

// Get the bidirectional tunnel
let mut upstream_io = resp.upgrade().await?;

// In a real proxy, splice with downstream:
// tokio::io::copy_bidirectional(&mut downstream_io, &mut upstream_io).await?;
Ok(())
}
}

HTTP/2 Extended CONNECT (RFC 8441)

When the request method is CONNECT and a Protocol extension is present, ForwardBuilder:

  • Forces HTTP/2 on the upstream connection
  • Uses the full URI (not path-only) so hyper generates correct pseudo-headers
  • Skips response hop-by-hop stripping
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, Protocol};
use bytes::Bytes;
use http_body_util::Full;

async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::builder()
    .http2_prior_knowledge()
    .build()?;

let mut req = http::Request::builder()
    .method(http::Method::CONNECT)
    .uri("http://h2-backend:8080/ws/chat")
    .body(Full::new(Bytes::new()))
    .unwrap();
req.extensions_mut().insert(Protocol::from_static("websocket"));

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

assert_eq!(resp.status(), http::StatusCode::OK);

let mut upstream_io = resp.upgrade().await?;
// Bidirectional tunnel is ready
Ok(())
}
}

Hooks

Use on_request and on_response for transformations not covered by other builder methods:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let incoming_req = http::Request::builder().uri("/test").body(Full::new(Bytes::new())).unwrap();
let resp = client
    .forward(incoming_req)
    .upstream("http://backend:8080".parse::<http::Uri>().unwrap())
    .on_request(|parts| {
        parts.headers.insert("x-request-id", "abc-123".parse().unwrap());
    })
    .on_response(|resp| {
        resp.headers_mut().insert("x-proxy", "aioduct".parse().unwrap());
    })
    .send()
    .await?;
Ok(())
}
}

gRPC / h2c Forwarding

For gRPC or other HTTP/2 cleartext (h2c) upstreams, use .h2c() to force HTTP/2 prior knowledge on an individual forward without requiring http2_prior_knowledge() on the entire client:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();

let grpc_req = http::Request::builder()
    .method("POST")
    .uri("/grpc.UserService/GetUser")
    .header("content-type", "application/grpc")
    .body(Full::new(Bytes::from("\0\0\0\0\x05hello")))
    .unwrap();

let resp = client
    .forward(grpc_req)
    .upstream("http://grpc-backend:50051".parse::<http::Uri>().unwrap())
    .h2c()
    .send()
    .await?;
Ok(())
}
}

Adaptive h2c

When you don’t know whether the upstream speaks h2c, use .adaptive_h2c(). On the first request to a given authority, it probes with an h2 prior knowledge handshake. If the upstream rejects it, the request falls back to HTTP/1.1 transparently. The result is cached per-authority so subsequent requests skip the probe:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();

let req = http::Request::builder()
    .method("POST")
    .uri("/api/data")
    .body(Full::new(Bytes::new()))
    .unwrap();

// First request probes; subsequent requests use cached result
let resp = client
    .forward(req)
    .upstream("http://backend:8080".parse::<http::Uri>().unwrap())
    .adaptive_h2c()
    .send()
    .await?;
Ok(())
}
}

Configure the probe cache TTL (default 5 minutes) on the client:

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use std::time::Duration;
let client = TokioClient::builder()
    .h2c_probe_ttl(Duration::from_secs(600))
    .build()?;
}

What ForwardBuilder Does NOT Do

  • No body buffering — the body streams through as-is
  • No middleware — redirects, cookies, cache, and decompression are all bypassed
  • No WebSocket framing — aioduct is transport-level; use a WS library for frame parsing
  • No bidirectional splice — the caller is responsible for splicing Upgraded streams
  • No plaintext h2 by default — HTTPS forwards negotiate HTTP/2 via TLS ALPN as usual; use .h2c() or .adaptive_h2c() when the upstream requires cleartext HTTP/2 (h2c)

Link Header Parsing

aioduct can parse Link headers (RFC 8288) from HTTP responses. Link headers are commonly used for pagination, resource discovery, and relation metadata.

Use Response::links() to extract all Link header values:

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, Link};

async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("https://api.example.com/items?page=1")?
    .send()
    .await?;

for link in resp.links() {
    println!("URI: {}", link.uri);
    if let Some(ref rel) = link.rel {
        println!("  rel: {rel}");
    }
}
Ok(())
}
}

The Link struct contains:

FieldTypeDescription
uriStringThe target URI
relOption<String>Relation type (e.g., next, prev, last)
titleOption<String>Human-readable title
media_typeOption<String>Expected media type of the target
anchorOption<String>Context URI for the link

Common Patterns

Pagination

Many APIs use Link headers for pagination:

Link: <https://api.example.com/items?page=2>; rel="next",
      <https://api.example.com/items?page=5>; rel="last"
#![allow(unused)]
fn main() {
use aioduct::response::Response;
fn next_page_url(resp: &Response) -> Option<String> {
    resp.links()
        .into_iter()
        .find(|l| l.rel.as_deref() == Some("next"))
        .map(|l| l.uri)
}
}

Direct Parsing

You can also parse Link headers directly from a HeaderMap:

#![allow(unused)]
fn main() {
use aioduct::link::parse_link_headers;
use http::HeaderMap;

let mut headers = HeaderMap::new();
headers.insert(
    "link",
    "<https://example.com>; rel=\"canonical\"".parse().unwrap(),
);

let links = parse_link_headers(&headers);
assert_eq!(links[0].rel.as_deref(), Some("canonical"));
}

Forwarded Header

aioduct provides a builder and parser for the Forwarded HTTP header (RFC 7239), which standardizes proxy-related metadata previously carried by X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host.

Building Forwarded Headers

Use ForwardedElement to construct header values:

#![allow(unused)]
fn main() {
use aioduct::ForwardedElement;

let elem = ForwardedElement::new()
    .forwarded_for("192.0.2.60")
    .proto("https")
    .host("example.com");

assert_eq!(
    elem.to_header_value(),
    "for=192.0.2.60;host=example.com;proto=https"
);
}

Parameters

Each ForwardedElement supports four parameters:

MethodParameterDescription
by()byThe proxy that received the request
forwarded_for()forThe client that made the request
host()hostThe original Host header value
proto()protoThe protocol used (http or https)

IPv6 Addresses

IPv6 addresses are automatically quoted and bracketed per the RFC:

#![allow(unused)]
fn main() {
use std::net::IpAddr;
use aioduct::ForwardedElement;

let ip: IpAddr = "2001:db8::1".parse().unwrap();
let elem = ForwardedElement::new().forwarded_for_ip(ip);
assert_eq!(elem.to_header_value(), r#"for="[2001:db8::1]""#);
}

Multiple Hops

Use format_forwarded() to join multiple elements (one per proxy hop):

#![allow(unused)]
fn main() {
use aioduct::forwarded::{ForwardedElement, format_forwarded};

let elems = vec![
    ForwardedElement::new().forwarded_for("192.0.2.43"),
    ForwardedElement::new().forwarded_for("198.51.100.17"),
];
assert_eq!(
    format_forwarded(&elems),
    "for=192.0.2.43, for=198.51.100.17"
);
}

Parsing

Parse a Forwarded header value back into elements:

#![allow(unused)]
fn main() {
use aioduct::forwarded::parse_forwarded;

let elems = parse_forwarded("for=192.0.2.60;proto=https, for=198.51.100.17");
assert_eq!(elems.len(), 2);
assert_eq!(elems[0].forwarded_for.as_deref(), Some("192.0.2.60"));
assert_eq!(elems[0].proto.as_deref(), Some("https"));
}

Request Timings

aioduct records the duration of each connection phase for every request. Access the breakdown via Response::timings().

Usage

use aioduct::TokioClient;

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

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

    if let Some(t) = resp.timings() {
        if let Some(dns) = t.dns() {
            println!("DNS:       {:?}", dns);
        }
        if let Some(tcp) = t.tcp_connect() {
            println!("TCP:       {:?}", tcp);
        }
        if let Some(tls) = t.tls_handshake() {
            println!("TLS:       {:?}", tls);
        }
        if let Some(ttfb) = t.transfer() {
            println!("Transfer:  {:?}", ttfb);
        }
        println!("Total:     {:?}", t.total());
    }

    Ok(())
}

Phases

AccessorMeasuresNone when
dns()Hostname resolutionLiteral IP, pool hit, or proxy-handled DNS
tcp_connect()TCP connection establishmentPool hit
tls_handshake()TLS handshake (rustls)Plain HTTP or pool hit
transfer()Request sent → first response byte (TTFB)Should not normally be None
total()Wall-clock start to response headersAlways present

Pool Hits

When a request reuses an existing pooled connection, the connection setup phases (DNS, TCP, TLS) are skipped and reported as None. Only transfer() and total() are populated.

HTTP/3

HTTP/3 connections include QUIC connection establishment time. The TLS handshake is part of the QUIC handshake and is reflected in the tls_handshake() timing.

Problem Details (RFC 9457)

aioduct can parse RFC 9457 Problem Details responses — a standardized JSON format for HTTP API errors with the application/problem+json content type.

Requires the json feature.

Parsing Problem Details

Use Response::problem_details() to check and parse a Problem Details response:

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProblemDetails};

async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("https://api.example.com/resource")?
    .send()
    .await?;

if let Some(result) = resp.problem_details().await {
    let problem: ProblemDetails = result?;
    println!("type: {:?}", problem.problem_type);
    println!("title: {:?}", problem.title);
    println!("status: {:?}", problem.status);
    println!("detail: {:?}", problem.detail);
}
Ok(())
}
}

The method returns None if the Content-Type is not application/problem+json.

ProblemDetails Fields

FieldTypeDescription
problem_typeOption<String>A URI identifying the problem type
titleOption<String>Short human-readable summary
statusOption<u16>The HTTP status code
detailOption<String>Detailed human-readable explanation
instanceOption<String>URI identifying the specific occurrence
extensionsHashMap<String, Value>Any additional fields

Example Response

A typical Problem Details response:

{
  "type": "https://example.com/probs/out-of-credit",
  "title": "You do not have enough credit.",
  "status": 403,
  "detail": "Your current balance is 30, but that costs 50.",
  "instance": "/account/12345/msgs/abc"
}

Extensions

Any JSON fields beyond the standard five are captured in the extensions map:

#![allow(unused)]
fn main() {
use aioduct::ProblemDetails;
fn example(problem: ProblemDetails) {
if let Some(balance) = problem.extensions.get("balance") {
    println!("balance: {balance}");
}
}
}

CLI Tools

The aioduct workspace includes two command-line tools that serve as both useful utilities and real-world integration examples of the library.

aioduct-aria

An aria2-inspired parallel download tool. It probes the server for Accept-Ranges support, splits the file into segments, and downloads them concurrently.

Usage

# Basic download
aioduct-aria https://releases.example.com/archive.tar.gz

# 16 parallel segments, save to specific file
aioduct-aria -s 16 -o local.tar.gz https://releases.example.com/archive.tar.gz

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

# Limit download speed to 5 MB/s
aioduct-aria --max-download-speed 5M https://releases.example.com/archive.tar.gz

# Through a proxy with custom headers
aioduct-aria --proxy http://proxy:8080 -H "Authorization: Bearer tok" https://example.com/file

Features

  • Segmented parallel downloads using HTTP Range requests
  • Automatic filename detection from Content-Disposition or URL
  • Resume support (-c) — skips already-downloaded segments
  • Progress bar with speed and ETA
  • Bandwidth limiting
  • Proxy support (HTTP and SOCKS5)
  • Custom headers, basic auth, bearer auth
  • Auto-rename to avoid overwriting existing files

aioduct-curl

A curl-inspired HTTP tool with familiar flags. Covers the most commonly used curl options.

Usage

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

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

# HEAD request (show headers only)
aioduct-curl -I https://example.com

# Follow redirects with basic auth
aioduct-curl -L -u user:pass https://httpbin.org/basic-auth/user/pass

# Save response to file
aioduct-curl -o page.html https://example.com

# Verbose output
aioduct-curl -v https://example.com

# Write-out format (status code)
aioduct-curl -w '%{http_code}\n' -o /dev/null -s https://example.com

Supported Flags

FlagLongDescription
-X--requestHTTP method
-d--dataRequest body (sets POST)
--data-binaryBinary request body (supports @file)
-F--formForm field (repeatable)
-H--headerExtra header (repeatable)
-A--user-agentUser-Agent string
-e--refererReferer URL
-u--userBasic auth (user:password)
--oauth2-bearerBearer token
-L--locationFollow redirects
--max-redirsMax redirect hops (default: 10)
-I--headHEAD request, show headers only
-i--includeInclude response headers in output
-v--verboseShow request and response headers
-s--silentSilent mode
-S--show-errorShow errors in silent mode
-o--outputWrite to file
-O--remote-nameSave using filename from URL
-D--dump-headerDump headers to file
-w--write-outFormat string (%{http_code})
-m--max-timeTotal request timeout (seconds)
--connect-timeoutConnection timeout (seconds)
--retryRetry count
-x--proxyProxy URL
-k--insecureSkip TLS verification
--http2Force HTTP/2
--limit-rateMax download speed (supports K/M/G)
--rawDisable decompression
--compressedRequest compressed response

Exit Codes

CodeMeaning
0Success
1Generic error
3Invalid URL
7Connection failed
22HTTP 4xx/5xx response
23Write error
28Timeout
60TLS error

Benchmarks

aioduct includes criterion benchmarks comparing HTTP client overhead against popular alternatives.

CrateVersionDescription
aioduct0.2.0This crate — hyper 1.x, no hyper-util, async-native
reqwest0.12The most popular Rust HTTP client, built on hyper + hyper-util + tower
hyper-util0.1hyper’s official high-level client (legacy::Client), minimal wrapper
isahc1.8Built on libcurl via curl-sys, independent HTTP stack

Setup

All benchmarks hit a local hyper server on loopback (127.0.0.1), eliminating network latency to isolate pure client overhead. Each benchmark reuses a single client and connection pool across iterations, measuring steady-state performance with warm connections.

Running

# All H1 benchmarks
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench h1

# All H2 benchmarks
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench h2

# JSON benchmarks
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench json

# Feature benchmarks (SSE, multipart, streaming, chunk download)
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench features

# Connection pool benchmarks
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench pooling

HTML reports are generated in target/criterion/.

Results

Measured on Linux 5.15, Rust 1.85, release profile. Times are the mean of 30–100 samples (lower is better).

HTTP/1.1 GET Request (bytes)

Simple GET, read entire response as Bytes.

ClientMeanvs aioduct
aioduct43.0 µs
hyper-util44.8 µs+4.2%
reqwest48.6 µs+13.0%
isahc91.3 µs+112.3%

HTTP/1.1 GET Request (text)

GET, read response as UTF-8 String.

ClientMeanvs aioduct
aioduct44.7 µs
reqwest47.5 µs+6.3%

JSON Deserialization

GET + deserialize a small JSON object ({"message":"hello","count":42}).

ClientMeanvs aioduct
aioduct43.6 µs
reqwest47.4 µs+8.7%

POST with 4 KB Body

POST a 4 KB string, read response bytes.

ClientMeanvs aioduct
aioduct53.3 µs
reqwest59.8 µs+12.2%
isahc76.2 µs+43.0%

Large Body Download (64 KB, HTTP/1.1)

GET a 64 KB response, read as bytes.

ClientMeanvs aioduct
hyper-util60.1 µs-4.0%
aioduct62.6 µs
reqwest64.5 µs+3.0%

Large Body Download (1 MB, HTTP/1.1)

GET a 1 MB response, read as bytes.

ClientMeanvs aioduct
aioduct465.8 µs
reqwest481.4 µs+3.3%

10 Concurrent Requests (HTTP/1.1)

10 GET requests dispatched via tokio::spawn, all awaited.

ClientMeanvs aioduct
aioduct124.3 µs
reqwest140.9 µs+13.4%

50 Concurrent Requests (HTTP/1.1)

50 GET requests dispatched via tokio::spawn, all awaited.

ClientMeanvs aioduct
aioduct361.5 µs
reqwest425.0 µs+17.6%

HTTP/2 GET Request

GET via h2c (HTTP/2 over cleartext).

ClientMeanvs aioduct
aioduct61.5 µs
hyper-util84.7 µs+37.7%

HTTP/2 Download (64 KB)

GET a 64 KB response via h2c.

ClientMeanvs aioduct
aioduct105.1 µs
hyper-util2,068 µs+1868%

(hyper-util h2 uses default 64 KB window sizes, hitting flow-control bottlenecks on larger payloads. aioduct configures 2 MB stream / 4 MB connection windows.)

HTTP/2 Download (1 MB)

GET a 1 MB response via h2c (aioduct only).

ClientMean
aioduct734.0 µs

HTTP/2 10 Concurrent Requests

10 concurrent requests multiplexed over a single h2c connection (aioduct only).

ClientMean
aioduct162.1 µs

HTTP/2 POST with 4 KB Body

POST a 4 KB payload via h2c (aioduct only).

ClientMean
aioduct87.7 µs

Connection Pool Overhead

Comparison of pooled vs no-pool (fresh connection per request).

ProtocolWith PoolNo PoolSpeedup
HTTP/1.144.8 µs95.4 µs2.1×
HTTP/280.6 µs191.4 µs2.4×

SSE: Consume 100 Events

Parse 100 Server-Sent Events from a single response (aioduct only).

ClientMean
aioduct65.4 µs

Multipart Upload (small)

Multipart form with two text fields.

ClientMeanvs aioduct
aioduct50.8 µs
reqwest66.6 µs+31.1%

Multipart Upload (1 MB file)

Multipart form with a 1 MB file part.

ClientMeanvs aioduct
aioduct846.3 µs
reqwest944.9 µs+11.7%

Streaming Upload (1 MB)

Stream a 1 MB body to an echo server.

ClientMeanvs aioduct
reqwest750.8 µs-2.6%
aioduct770.9 µs

Chunk Download (1 MB)

Parallel range-based download of a 1 MB file.

ChunksMean
1 chunk2,239 µs
4 chunks2,308 µs
8 chunks2,297 µs
Single GET (baseline)362.5 µs

(On loopback the overhead of multiple range requests exceeds the parallelism benefit. Chunk download shows gains on real networks with higher latency.)

Body Stream (64 KB)

Read a 64 KB response frame-by-frame vs collected as bytes (aioduct only).

MethodMean
bytes collect56.0 µs
frame by frame69.3 µs

Analysis

  • aioduct is the fastest or tied for fastest in most benchmarks, sitting close to raw hyper-util while providing a much higher-level API (connection pooling, redirects, cookies, middleware, retry, etc.).
  • hyper-util (legacy::Client) is close to aioduct in H1 but struggles in H2 due to default flow-control window sizes.
  • reqwest is 3–31% slower than aioduct in most scenarios. The gap widens for concurrent workloads and multipart uploads.
  • isahc is 43–112% slower due to the libcurl FFI boundary and curl’s internal buffering.
  • Connection pooling provides a consistent ~2× speedup over fresh connections for both H1 and H2.

Caveats

  • These benchmarks measure loopback HTTP client overhead only. In real-world usage, TLS handshakes and network latency dominate.
  • reqwest uses native-tls by default (disabled here since we test plain HTTP).
  • isahc uses libcurl which has its own connection pooling; the curl overhead is most visible on small payloads.
  • The H2 comparison is not apples-to-apples: aioduct configures larger flow-control windows. With matching configuration hyper-util would be closer.
  • Results vary by machine, OS, and Rust version. Run the benchmarks yourself for your environment.

Benchmark Suites

SuiteBench FileScenarios
h1benches/h1.rsGET bytes/text, POST 4K, download 64K/1M, concurrent 10/50
h2benches/h2.rsGET, download 64K/1M, concurrent 10, POST 4K
jsonbenches/json.rsJSON deserialization (GET + serde)
featuresbenches/features.rsSSE, multipart, upload 1M, chunk download, body stream
poolingbenches/pooling.rsH1/H2 with-pool vs no-pool

API Reference

This page covers the main types and their methods. For full documentation, see cargo doc --features tokio,rustls,rustls-ring,json.

Client Types

aioduct provides ergonomic type aliases for the most common configurations:

Type AliasExpands ToRuntime
TokioClientHttpEngineSend<TokioRuntime, tokio_rt::TcpConnector>tokio
SmolClientHttpEngineSend<SmolRuntime, smol_rt::TcpConnector>smol
CompioClientHttpEngineLocal<CompioRuntime, compio_rt::TcpConnector>compio

Construction

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use std::time::Duration;

// Default configuration
let client = TokioClient::new();

// With rustls TLS (requires `rustls` and exactly one rustls provider)
let client = TokioClient::with_rustls();

// Custom configuration via builder
let client = TokioClient::builder()
    .timeout(Duration::from_secs(30))
    .max_redirects(5)
    .pool_idle_timeout(Duration::from_secs(90))
    .pool_max_idle_per_host(10)
    .build()?;
}

HTTP Methods

MethodDescription
get(url)Start a GET request
head(url)Start a HEAD request
post(url)Start a POST request
put(url)Start a PUT request
patch(url)Start a PATCH request
delete(url)Start a DELETE request
request(method, url)Start a request with any HTTP method

All methods return Result<RequestBuilderSend> (or Result<RequestBuilderLocal> for HttpEngineLocal) — the URL is parsed immediately and invalid URLs produce an error.

HttpEngineBuilder Options

HttpEngineBuilder<R, C> is returned by TokioClient::builder(connector) (and similarly for other client types).

MethodDefaultDescription
timeout(Duration)NoneDefault timeout for all requests
connect_timeout(Duration)NoneTimeout for TCP connect + TLS handshake
read_timeout(Duration)NoneTimeout between body data chunks
tcp_keepalive(Duration)NoneEnable TCP keepalive with given interval
local_address(IpAddr)NoneBind outgoing connections to a local IP
max_redirects(usize)10Maximum redirect hops (0 = disabled)
referer(bool)falseSet Referer header on redirects
https_only(bool)falseReject non-HTTPS URLs
pool_idle_timeout(Duration)90sIdle connection lifetime
pool_max_idle_per_host(usize)10Max idle connections per origin
default_headers(HeaderMap)User-AgentHeaders applied to every request
no_default_headers()Remove all default headers
tls(RustlsConnector)NoneCustom TLS configuration
danger_accept_invalid_certs()Accept any TLS certificate (INSECURE)
no_decompression()Disable automatic response decompression
system_proxy()Read proxy from HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars
proxy_settings(ProxySettings)NoneFine-grained HTTP/HTTPS proxy with bypass rules
http2(Http2Config)NoneConfigure HTTP/2 parameters (window sizes, keepalive, frame size)
middleware(impl Middleware)NoneAdd a middleware layer that can inspect/modify requests and responses
retry(RetryConfig)NoneDefault retry policy for all requests
cookie_jar(CookieJar)NoneEnable automatic cookie management
rate_limiter(RateLimiter)NoneToken-bucket rate limiter for outgoing requests
cache(HttpCache)NoneEnable in-memory HTTP response caching

RequestBuilderSend / RequestBuilderLocal

Fluent builder for configuring a single request. RequestBuilderSend is returned by HttpEngineSend methods; RequestBuilderLocal is returned by HttpEngineLocal methods. Both implement RequestBuilderExt.

Headers

#![allow(unused)]
fn main() {
use aioduct::{TokioClient, HeaderMap};
let client = TokioClient::new();
// Typed header
use http::header::{HeaderName, HeaderValue, ACCEPT};
let rb = client.get("http://example.com").unwrap()
    .header(ACCEPT, HeaderValue::from_static("application/json"));

// String header (fallible)
let rb = client.get("http://example.com").unwrap()
    .header_str("x-custom", "value").unwrap();

// Bulk headers
let mut headers = HeaderMap::new();
headers.insert("x-a", "1".parse().unwrap());
headers.insert("x-b", "2".parse().unwrap());
let rb = client.get("http://example.com").unwrap()
    .headers(headers);
}

Authentication

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
// Bearer token
let rb = client.get("http://example.com").unwrap()
    .bearer_auth("my-token");

// Basic auth
let rb = client.get("http://example.com").unwrap()
    .basic_auth("user", Some("password"));
}

Body

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
// Raw bytes
let rb = client.post("http://example.com").unwrap()
    .body("raw body content");

// URL-encoded form
let rb = client.post("http://example.com").unwrap()
    .form(&[("username", "admin"), ("password", "secret")]);

// JSON (requires `json` feature)
// let rb = client.post("http://example.com").unwrap()
//     .json(&my_struct).unwrap();
}

Query Parameters

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
let rb = client.get("http://example.com/search").unwrap()
    .query(&[("q", "hello world"), ("page", "1")]);
// Sends: GET /search?q=hello%20world&page=1
}

Other Options

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
use std::time::Duration;

let rb = client.get("http://example.com").unwrap()
    .timeout(Duration::from_secs(5))     // per-request timeout
    .version(http::Version::HTTP_11);    // force HTTP version

// HTTP upgrade (WebSocket)
let rb = client.get("http://example.com/ws").unwrap()
    .upgrade();  // sets Connection: Upgrade, Upgrade: websocket, HTTP/1.1
}

Sending

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com")?.send().await?;
Ok(())
}
}

ResponseBodySend / ResponseBodyLocal

The response type returned after sending a request. ResponseBodySend is returned by HttpEngineSend; ResponseBodyLocal by HttpEngineLocal. Both implement ResponseExt.

Inspecting

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com")?.send().await?;
let status = resp.status();           // StatusCode
let headers = resp.headers();         // &HeaderMap
let version = resp.version();         // Version
let length = resp.content_length();   // Option<u64>
let url = resp.url();                 // &Uri — final URL after redirects
Ok(())
}
}

Error on Status

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
// Consume the response, returning Err for 4xx/5xx
let resp = client.get("http://example.com")?.send().await?
    .error_for_status()?;

// Non-consuming variant
let resp = client.get("http://example.com")?.send().await?;
resp.error_for_status_ref()?;
let text = resp.text().await?;
Ok(())
}
}

Consuming the Body

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
// As bytes
let bytes = client.get("http://example.com")?.send().await?.bytes().await?;

// As string
let text = client.get("http://example.com")?.send().await?.text().await?;

// As JSON (requires `json` feature)
// let data: MyStruct = resp.json().await?;

// Raw hyper body
let body = client.get("http://example.com")?.send().await?.into_body();

// HTTP upgrade (WebSocket) — after 101 response
// let upgraded = resp.upgrade().await?;
Ok(())
}
}

Portable Traits

These traits provide a common interface across both Send and Local engine variants:

TraitDescription
HttpClientCommon client interface (get, post, etc.)
RequestBuilderExtCommon request builder methods (header, body, etc.)
ResponseExtCommon response methods (status, text, bytes, etc.)
ByteStreamExtStreaming body helpers

Use these traits to write generic code that works with any aioduct client type.

Redirects

aioduct follows redirects automatically (up to max_redirects, default 10):

StatusBehavior
301Follow with GET, drop body + content headers
302Follow with GET, drop body + content headers
303Follow with GET, drop body + content headers
307Follow with original method + body
308Follow with original method + body

Sensitive headers (Authorization, Cookie, Proxy-Authorization) are automatically stripped when redirecting to a different origin.

Disable with .max_redirects(0) on the builder.

Error Types

#![allow(unused)]
fn main() {
use aioduct::Error;

// Error variants:
// Error::Http(_)         — http crate errors
// Error::Hyper(_)        — hyper protocol errors
// Error::Io(_)           — I/O errors
// Error::Tls(_)          — TLS errors
// Error::Pool(_)         — connection pool errors
// Error::Timeout         — request timed out
// Error::InvalidUrl(_)   — URL parse or scheme errors
// Error::Status(_)       — HTTP 4xx/5xx from error_for_status()
// Error::Other(_)        — other boxed errors
}

Error Convenience Methods

MethodDescription
is_closed()Returns true if the error is a closed connection
is_timeout()Returns true if the error is a timeout
is_connect()Returns true if the error occurred during connect